feat: scaffold manager server with docker lifecycle and bearer-auth proxy
Lays the groundwork for remote-mcp-manager: an Express server that runs installed MCP containers via dockerode (forcing host/port env vars, no published ports, shared bridge network) and reverse-proxies /mcp/:slug requests behind a per-installation Bearer token, resolving the upstream target by inspecting the container's network IP rather than relying on container-name DNS. Validated end-to-end against a real cloned MCP repo (nixpacks build + container run + authenticated proxy roundtrip); installations are still a hardcoded in-memory list, replaced by SQLite in the next slice. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
23
apps/server/package.json
Normal file
23
apps/server/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@remote-mcp-manager/server",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dockerode": "4.0.9",
|
||||
"express": "4.21.2",
|
||||
"http-proxy": "1.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dockerode": "3.3.42",
|
||||
"@types/express": "4.17.23",
|
||||
"@types/http-proxy": "1.17.16",
|
||||
"@types/node": "24.9.1",
|
||||
"tsx": "4.20.6",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
6
apps/server/src/config/env.ts
Normal file
6
apps/server/src/config/env.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const config = {
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
// Docker in prod; a rootless Podman socket (e.g. /run/user/<uid>/podman/podman.sock) in local dev.
|
||||
dockerSocketPath: process.env.DOCKER_SOCKET_PATH ?? "/var/run/docker.sock",
|
||||
mcpNetworkName: process.env.MCP_NETWORK_NAME ?? "mcp-net",
|
||||
};
|
||||
51
apps/server/src/docker/containers.ts
Normal file
51
apps/server/src/docker/containers.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import Docker from "dockerode";
|
||||
import { config } from "../config/env.js";
|
||||
import type { Installation } from "../installations.js";
|
||||
|
||||
const docker = new Docker({ socketPath: config.dockerSocketPath });
|
||||
|
||||
export async function ensureNetwork(name: string): Promise<void> {
|
||||
const networks = await docker.listNetworks({ filters: { name: [name] } });
|
||||
if (networks.length === 0) {
|
||||
await docker.createNetwork({ Name: name, Driver: "bridge" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function runInstallationContainer(installation: Installation): Promise<void> {
|
||||
await ensureNetwork(config.mcpNetworkName);
|
||||
|
||||
const existing = docker.getContainer(installation.containerName);
|
||||
try {
|
||||
await existing.remove({ force: true });
|
||||
} catch {
|
||||
// container didn't exist yet, nothing to clean up
|
||||
}
|
||||
|
||||
const env = {
|
||||
...installation.env,
|
||||
[installation.portEnvVar]: String(installation.internalPort),
|
||||
[installation.hostEnvVar]: "0.0.0.0",
|
||||
};
|
||||
|
||||
const container = await docker.createContainer({
|
||||
name: installation.containerName,
|
||||
Image: installation.imageTag,
|
||||
Env: Object.entries(env).map(([k, v]) => `${k}=${v}`),
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: "unless-stopped" },
|
||||
NetworkMode: config.mcpNetworkName,
|
||||
},
|
||||
});
|
||||
|
||||
await container.start();
|
||||
}
|
||||
|
||||
export async function getContainerIp(containerName: string): Promise<string> {
|
||||
const container = docker.getContainer(containerName);
|
||||
const info = await container.inspect();
|
||||
const network = info.NetworkSettings.Networks[config.mcpNetworkName];
|
||||
if (!network?.IPAddress) {
|
||||
throw new Error(`Container ${containerName} has no IP on network ${config.mcpNetworkName}`);
|
||||
}
|
||||
return network.IPAddress;
|
||||
}
|
||||
13
apps/server/src/index.ts
Normal file
13
apps/server/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import express from "express";
|
||||
import { config } from "./config/env.js";
|
||||
import { handleMcpProxyRequest } from "./proxy/mcpProxy.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.get("/health", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
app.all("/mcp/:slug", handleMcpProxyRequest);
|
||||
|
||||
app.listen(config.port, () => {
|
||||
console.log(`remote-mcp-manager listening on http://localhost:${config.port}`);
|
||||
});
|
||||
30
apps/server/src/installations.ts
Normal file
30
apps/server/src/installations.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface Installation {
|
||||
slug: string;
|
||||
imageTag: string;
|
||||
containerName: string;
|
||||
internalPort: number;
|
||||
httpPath: string;
|
||||
portEnvVar: string;
|
||||
hostEnvVar: string;
|
||||
env: Record<string, string>;
|
||||
bearerToken: string;
|
||||
}
|
||||
|
||||
// Slice 2: hardcoded registry, replaced by SQLite-backed storage in slice 3.
|
||||
export const installations: Installation[] = [
|
||||
{
|
||||
slug: "social",
|
||||
imageTag: "localhost/mcpmgr-test-social:latest",
|
||||
containerName: "mcp-social",
|
||||
internalPort: 8080,
|
||||
httpPath: "/mcp",
|
||||
portEnvVar: "PORT",
|
||||
hostEnvVar: "HOST",
|
||||
env: {},
|
||||
bearerToken: "dev-slice2-token",
|
||||
},
|
||||
];
|
||||
|
||||
export function findInstallationBySlug(slug: string): Installation | undefined {
|
||||
return installations.find((i) => i.slug === slug);
|
||||
}
|
||||
49
apps/server/src/proxy/mcpProxy.ts
Normal file
49
apps/server/src/proxy/mcpProxy.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { Request, Response } from "express";
|
||||
import httpProxy from "http-proxy";
|
||||
import { findInstallationBySlug } from "../installations.js";
|
||||
import { getContainerIp } from "../docker/containers.js";
|
||||
|
||||
const proxy = httpProxy.createProxyServer();
|
||||
proxy.on("error", (err, _req, res) => {
|
||||
if (!res.headersSent) {
|
||||
(res as Response).status(502).json({ error: "proxy error", detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
function tokensMatch(expected: string, provided: string): boolean {
|
||||
const a = Buffer.from(expected);
|
||||
const b = Buffer.from(provided);
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export async function handleMcpProxyRequest(req: Request, res: Response): Promise<void> {
|
||||
const { slug } = req.params;
|
||||
const installation = findInstallationBySlug(slug);
|
||||
if (!installation) {
|
||||
res.status(404).json({ error: "unknown installation" });
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeader = req.get("authorization") ?? "";
|
||||
const provided = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
||||
if (!provided || !tokensMatch(installation.bearerToken, provided)) {
|
||||
res.status(401).json({ error: "unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
let targetIp: string;
|
||||
try {
|
||||
targetIp = await getContainerIp(installation.containerName);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: "installation unreachable", detail: (err as Error).message });
|
||||
return;
|
||||
}
|
||||
|
||||
req.url = installation.httpPath;
|
||||
proxy.web(req, res, {
|
||||
target: `http://${targetIp}:${installation.internalPort}`,
|
||||
changeOrigin: true,
|
||||
});
|
||||
}
|
||||
13
apps/server/tsconfig.json
Normal file
13
apps/server/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user