From c918b32e72137d76d742d6f7163fc1dc05ac0f63 Mon Sep 17 00:00:00 2001 From: Dasemu Date: Thu, 8 Oct 2026 07:05:56 +0200 Subject: [PATCH] feat: support publishing a container's port to host loopback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some installations need to stay reachable by a native process on the same host outside the manager's own proxy — e.g. a legacy nginx location serving media/signed URLs for an app that also happens to be an MCP server. Previously this required manually recreating the container outside the manager's lifecycle (lost on the next build/redeploy). publishHostPort makes it a real, persisted setting. Co-Authored-By: Claude Sonnet 5 --- apps/server/src/api/installations.ts | 14 ++++++++++++-- apps/server/src/db/index.ts | 1 + apps/server/src/db/installations.ts | 7 +++++++ apps/server/src/docker/containers.ts | 10 ++++++++++ apps/server/src/service/orchestrator.ts | 1 + 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/server/src/api/installations.ts b/apps/server/src/api/installations.ts index 4755042..58bfb93 100644 --- a/apps/server/src/api/installations.ts +++ b/apps/server/src/api/installations.ts @@ -58,8 +58,17 @@ installationsRouter.post("/", async (req, res) => { }); installationsRouter.patch("/:id/config", (req, res) => { - const { env, portEnvVar, hostEnvVar, httpPath, internalPort, basicAuthUsername, basicAuthPassword, startCommand } = - req.body ?? {}; + const { + env, + portEnvVar, + hostEnvVar, + httpPath, + internalPort, + basicAuthUsername, + basicAuthPassword, + startCommand, + publishHostPort, + } = req.body ?? {}; try { const installation = configureInstallation(req.params.id, { env, @@ -70,6 +79,7 @@ installationsRouter.patch("/:id/config", (req, res) => { basicAuthUsername, basicAuthPassword, startCommand, + publishHostPort, }); res.json(toPublicJson(installation)); } catch (err) { diff --git a/apps/server/src/db/index.ts b/apps/server/src/db/index.ts index 4b1b980..ed741f6 100644 --- a/apps/server/src/db/index.ts +++ b/apps/server/src/db/index.ts @@ -37,6 +37,7 @@ for (const ddl of [ "ALTER TABLE installations ADD COLUMN basic_auth_username TEXT", "ALTER TABLE installations ADD COLUMN basic_auth_password_encrypted TEXT", "ALTER TABLE installations ADD COLUMN start_command TEXT", + "ALTER TABLE installations ADD COLUMN publish_host_port INTEGER", ]) { try { db.exec(ddl); diff --git a/apps/server/src/db/installations.ts b/apps/server/src/db/installations.ts index dc718f1..73048b1 100644 --- a/apps/server/src/db/installations.ts +++ b/apps/server/src/db/installations.ts @@ -32,6 +32,7 @@ export interface Installation { basicAuthUsername: string | null; basicAuthPassword: string | null; startCommand: string | null; + publishHostPort: number | null; createdAt: string; updatedAt: string; } @@ -56,6 +57,7 @@ interface Row { basic_auth_username: string | null; basic_auth_password_encrypted: string | null; start_command: string | null; + publish_host_port: number | null; created_at: string; updated_at: string; } @@ -81,6 +83,7 @@ function rowToInstallation(row: Row): Installation { basicAuthUsername: row.basic_auth_username, basicAuthPassword: row.basic_auth_password_encrypted ? decryptSecret(row.basic_auth_password_encrypted) : null, startCommand: row.start_command, + publishHostPort: row.publish_host_port, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -144,6 +147,7 @@ export interface InstallationUpdate { basicAuthUsername?: string | null; basicAuthPassword?: string | null; startCommand?: string | null; + publishHostPort?: number | null; gitRef?: string | null; } @@ -167,6 +171,7 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta basicAuthPassword: patch.basicAuthPassword !== undefined ? patch.basicAuthPassword : current.basicAuthPassword, startCommand: patch.startCommand !== undefined ? patch.startCommand : current.startCommand, + publishHostPort: patch.publishHostPort !== undefined ? patch.publishHostPort : current.publishHostPort, gitRef: patch.gitRef !== undefined ? patch.gitRef : current.gitRef, }; @@ -185,6 +190,7 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta basic_auth_username = @basicAuthUsername, basic_auth_password_encrypted = @basicAuthPasswordEncrypted, start_command = @startCommand, + publish_host_port = @publishHostPort, git_ref = @gitRef, updated_at = @updatedAt WHERE id = @id`, @@ -203,6 +209,7 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta basicAuthUsername: next.basicAuthUsername, basicAuthPasswordEncrypted: next.basicAuthPassword ? encryptSecret(next.basicAuthPassword) : null, startCommand: next.startCommand, + publishHostPort: next.publishHostPort, gitRef: next.gitRef, updatedAt: new Date().toISOString(), }); diff --git a/apps/server/src/docker/containers.ts b/apps/server/src/docker/containers.ts index 701fe3d..20b2a74 100644 --- a/apps/server/src/docker/containers.ts +++ b/apps/server/src/docker/containers.ts @@ -32,10 +32,19 @@ export async function runInstallationContainer(installation: Installation): Prom [installation.hostEnvVar]: "0.0.0.0", }; + // Installations are unreachable from outside mcp-net by design — every MCP client goes + // through the manager's own reverse proxy. publishHostPort is the one deliberate exception: + // it publishes the container's port to the host's loopback, for a native process on the same + // host (e.g. a legacy nginx location) that needs to reach this container directly. + const portBinding = installation.publishHostPort + ? { [`${installation.internalPort}/tcp`]: [{ HostIp: "127.0.0.1", HostPort: String(installation.publishHostPort) }] } + : undefined; + const container = await docker.createContainer({ name: installation.containerName, Image: installation.imageTag, Env: Object.entries(env).map(([k, v]) => `${k}=${v}`), + ...(portBinding ? { ExposedPorts: { [`${installation.internalPort}/tcp`]: {} } } : {}), HostConfig: { RestartPolicy: { Name: "unless-stopped" }, NetworkMode: config.mcpNetworkName, @@ -45,6 +54,7 @@ export async function runInstallationContainer(installation: Installation): Prom // real deploy; the outer host's own cap (if any) is the only ceiling in that case. ...(config.containerMemoryMb > 0 ? { Memory: config.containerMemoryMb * 1024 * 1024 } : {}), ...(config.containerCpus > 0 ? { NanoCpus: Math.round(config.containerCpus * 1e9) } : {}), + ...(portBinding ? { PortBindings: portBinding } : {}), }, }); diff --git a/apps/server/src/service/orchestrator.ts b/apps/server/src/service/orchestrator.ts index 337ca1f..7215b90 100644 --- a/apps/server/src/service/orchestrator.ts +++ b/apps/server/src/service/orchestrator.ts @@ -56,6 +56,7 @@ export function configureInstallation( basicAuthUsername?: string | null; basicAuthPassword?: string | null; startCommand?: string | null; + publishHostPort?: number | null; }, ): Installation { return updateInstallation(id, patch);