feat: support publishing a container's port to host loopback
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
@@ -58,8 +58,17 @@ installationsRouter.post("/", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
installationsRouter.patch("/:id/config", (req, res) => {
|
installationsRouter.patch("/:id/config", (req, res) => {
|
||||||
const { env, portEnvVar, hostEnvVar, httpPath, internalPort, basicAuthUsername, basicAuthPassword, startCommand } =
|
const {
|
||||||
req.body ?? {};
|
env,
|
||||||
|
portEnvVar,
|
||||||
|
hostEnvVar,
|
||||||
|
httpPath,
|
||||||
|
internalPort,
|
||||||
|
basicAuthUsername,
|
||||||
|
basicAuthPassword,
|
||||||
|
startCommand,
|
||||||
|
publishHostPort,
|
||||||
|
} = req.body ?? {};
|
||||||
try {
|
try {
|
||||||
const installation = configureInstallation(req.params.id, {
|
const installation = configureInstallation(req.params.id, {
|
||||||
env,
|
env,
|
||||||
@@ -70,6 +79,7 @@ installationsRouter.patch("/:id/config", (req, res) => {
|
|||||||
basicAuthUsername,
|
basicAuthUsername,
|
||||||
basicAuthPassword,
|
basicAuthPassword,
|
||||||
startCommand,
|
startCommand,
|
||||||
|
publishHostPort,
|
||||||
});
|
});
|
||||||
res.json(toPublicJson(installation));
|
res.json(toPublicJson(installation));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ for (const ddl of [
|
|||||||
"ALTER TABLE installations ADD COLUMN basic_auth_username TEXT",
|
"ALTER TABLE installations ADD COLUMN basic_auth_username TEXT",
|
||||||
"ALTER TABLE installations ADD COLUMN basic_auth_password_encrypted TEXT",
|
"ALTER TABLE installations ADD COLUMN basic_auth_password_encrypted TEXT",
|
||||||
"ALTER TABLE installations ADD COLUMN start_command TEXT",
|
"ALTER TABLE installations ADD COLUMN start_command TEXT",
|
||||||
|
"ALTER TABLE installations ADD COLUMN publish_host_port INTEGER",
|
||||||
]) {
|
]) {
|
||||||
try {
|
try {
|
||||||
db.exec(ddl);
|
db.exec(ddl);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface Installation {
|
|||||||
basicAuthUsername: string | null;
|
basicAuthUsername: string | null;
|
||||||
basicAuthPassword: string | null;
|
basicAuthPassword: string | null;
|
||||||
startCommand: string | null;
|
startCommand: string | null;
|
||||||
|
publishHostPort: number | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -56,6 +57,7 @@ interface Row {
|
|||||||
basic_auth_username: string | null;
|
basic_auth_username: string | null;
|
||||||
basic_auth_password_encrypted: string | null;
|
basic_auth_password_encrypted: string | null;
|
||||||
start_command: string | null;
|
start_command: string | null;
|
||||||
|
publish_host_port: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -81,6 +83,7 @@ function rowToInstallation(row: Row): Installation {
|
|||||||
basicAuthUsername: row.basic_auth_username,
|
basicAuthUsername: row.basic_auth_username,
|
||||||
basicAuthPassword: row.basic_auth_password_encrypted ? decryptSecret(row.basic_auth_password_encrypted) : null,
|
basicAuthPassword: row.basic_auth_password_encrypted ? decryptSecret(row.basic_auth_password_encrypted) : null,
|
||||||
startCommand: row.start_command,
|
startCommand: row.start_command,
|
||||||
|
publishHostPort: row.publish_host_port,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
};
|
};
|
||||||
@@ -144,6 +147,7 @@ export interface InstallationUpdate {
|
|||||||
basicAuthUsername?: string | null;
|
basicAuthUsername?: string | null;
|
||||||
basicAuthPassword?: string | null;
|
basicAuthPassword?: string | null;
|
||||||
startCommand?: string | null;
|
startCommand?: string | null;
|
||||||
|
publishHostPort?: number | null;
|
||||||
gitRef?: string | null;
|
gitRef?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +171,7 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta
|
|||||||
basicAuthPassword:
|
basicAuthPassword:
|
||||||
patch.basicAuthPassword !== undefined ? patch.basicAuthPassword : current.basicAuthPassword,
|
patch.basicAuthPassword !== undefined ? patch.basicAuthPassword : current.basicAuthPassword,
|
||||||
startCommand: patch.startCommand !== undefined ? patch.startCommand : current.startCommand,
|
startCommand: patch.startCommand !== undefined ? patch.startCommand : current.startCommand,
|
||||||
|
publishHostPort: patch.publishHostPort !== undefined ? patch.publishHostPort : current.publishHostPort,
|
||||||
gitRef: patch.gitRef !== undefined ? patch.gitRef : current.gitRef,
|
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_username = @basicAuthUsername,
|
||||||
basic_auth_password_encrypted = @basicAuthPasswordEncrypted,
|
basic_auth_password_encrypted = @basicAuthPasswordEncrypted,
|
||||||
start_command = @startCommand,
|
start_command = @startCommand,
|
||||||
|
publish_host_port = @publishHostPort,
|
||||||
git_ref = @gitRef,
|
git_ref = @gitRef,
|
||||||
updated_at = @updatedAt
|
updated_at = @updatedAt
|
||||||
WHERE id = @id`,
|
WHERE id = @id`,
|
||||||
@@ -203,6 +209,7 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta
|
|||||||
basicAuthUsername: next.basicAuthUsername,
|
basicAuthUsername: next.basicAuthUsername,
|
||||||
basicAuthPasswordEncrypted: next.basicAuthPassword ? encryptSecret(next.basicAuthPassword) : null,
|
basicAuthPasswordEncrypted: next.basicAuthPassword ? encryptSecret(next.basicAuthPassword) : null,
|
||||||
startCommand: next.startCommand,
|
startCommand: next.startCommand,
|
||||||
|
publishHostPort: next.publishHostPort,
|
||||||
gitRef: next.gitRef,
|
gitRef: next.gitRef,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,10 +32,19 @@ export async function runInstallationContainer(installation: Installation): Prom
|
|||||||
[installation.hostEnvVar]: "0.0.0.0",
|
[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({
|
const container = await docker.createContainer({
|
||||||
name: installation.containerName,
|
name: installation.containerName,
|
||||||
Image: installation.imageTag,
|
Image: installation.imageTag,
|
||||||
Env: Object.entries(env).map(([k, v]) => `${k}=${v}`),
|
Env: Object.entries(env).map(([k, v]) => `${k}=${v}`),
|
||||||
|
...(portBinding ? { ExposedPorts: { [`${installation.internalPort}/tcp`]: {} } } : {}),
|
||||||
HostConfig: {
|
HostConfig: {
|
||||||
RestartPolicy: { Name: "unless-stopped" },
|
RestartPolicy: { Name: "unless-stopped" },
|
||||||
NetworkMode: config.mcpNetworkName,
|
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.
|
// 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.containerMemoryMb > 0 ? { Memory: config.containerMemoryMb * 1024 * 1024 } : {}),
|
||||||
...(config.containerCpus > 0 ? { NanoCpus: Math.round(config.containerCpus * 1e9) } : {}),
|
...(config.containerCpus > 0 ? { NanoCpus: Math.round(config.containerCpus * 1e9) } : {}),
|
||||||
|
...(portBinding ? { PortBindings: portBinding } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export function configureInstallation(
|
|||||||
basicAuthUsername?: string | null;
|
basicAuthUsername?: string | null;
|
||||||
basicAuthPassword?: string | null;
|
basicAuthPassword?: string | null;
|
||||||
startCommand?: string | null;
|
startCommand?: string | null;
|
||||||
|
publishHostPort?: number | null;
|
||||||
},
|
},
|
||||||
): Installation {
|
): Installation {
|
||||||
return updateInstallation(id, patch);
|
return updateInstallation(id, patch);
|
||||||
|
|||||||
Reference in New Issue
Block a user