feat: SQLite-backed installations, REST API, admin auth and web UI

Replaces the slice-2 hardcoded installation with a full install/deploy
pipeline:

- SQLite persistence (better-sqlite3) with a single `installations`
  table and a draft -> cloning -> building -> built -> starting ->
  running/error/stopped state machine; env vars and bearer tokens are
  encrypted at rest (AES-256-GCM, key derived from MASTER_KEY).
- Build/run orchestration: git clone (+ .env.example key detection),
  `nixpacks build` as a logged subprocess, dockerode container
  run/stop with forced host/port env vars and a TCP-connect health
  check before flipping an installation to "running".
- REST API (installations CRUD, build/deploy/redeploy, logs, token
  regeneration) behind single-operator session-cookie auth with a
  basic login lockout.
- Vite+React admin SPA: login, installation list, install wizard, and
  a detail page to configure env vars, trigger build/deploy/redeploy,
  view the build log, and copy the authenticated /mcp/<slug> URL.
- Dockerfile (multi-stage, pulls in nixpacks + the Docker CLI) and
  docker-compose.yml for production deployment.

Validated end-to-end against a real cloned repo: draft creation with
env-key detection, build, the error path on a failed health check, and
delete, all through the live API.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 01:42:06 +02:00
parent 309e6aedc3
commit b8f5d8253f
33 changed files with 3502 additions and 42 deletions

View File

@@ -8,3 +8,19 @@ DOCKER_SOCKET_PATH=/var/run/docker.sock
# Name of the shared container network joining the manager and every installed MCP container # Name of the shared container network joining the manager and every installed MCP container
MCP_NETWORK_NAME=mcp-net MCP_NETWORK_NAME=mcp-net
# Where the manager stores its SQLite DB, cloned repos and build logs
DATA_DIR=./data
# Public base URL used to build the authenticated MCP links shown in the UI, e.g. https://mcp.example.com
PUBLIC_BASE_URL=http://localhost:3000
# Password for the single admin/operator account that can use the panel
ADMIN_PASSWORD=change-me
# Random secret used to sign the admin session cookie
SESSION_SECRET=change-me-too
# Random string (min 32 chars) used to derive the AES-256-GCM key that encrypts
# installation env vars and bearer tokens at rest
MASTER_KEY=change-me-to-a-random-string-of-at-least-32-characters

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ dist/
data/ data/
.env .env
*.log *.log
*.tsbuildinfo

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM ghcr.io/railwayapp/nixpacks:1.41.0 AS nixpacks
FROM docker:27-cli AS dockercli
FROM node:20-slim AS build
WORKDIR /repo
COPY package.json package-lock.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
RUN npm ci
COPY apps/server apps/server
COPY apps/web apps/web
RUN npm run build --workspace apps/web
RUN npm run build --workspace apps/server
FROM node:20-slim
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=nixpacks /usr/bin/nixpacks /usr/local/bin/nixpacks
COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker
WORKDIR /repo
COPY package.json package-lock.json ./
COPY apps/server/package.json apps/server/package.json
RUN npm ci --omit=dev --workspace apps/server
COPY --from=build /repo/apps/server/dist apps/server/dist
COPY --from=build /repo/apps/web/dist apps/web/dist
WORKDIR /repo/apps/server
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/index.js"]

40
README.md Normal file
View File

@@ -0,0 +1,40 @@
# remote-mcp-manager
Self-hosted manager for MCP servers: install one from a GitHub repo URL, configure
its env vars, build it with [Nixpacks](https://nixpacks.com) (no Dockerfile needed
in the source repo), run it in a container, and get back an authenticated
`https://<your-domain>/mcp/<slug>` URL guarded by a bearer token.
## Quick start (production, Docker)
```sh
cp .env.example .env # fill in ADMIN_PASSWORD, SESSION_SECRET, MASTER_KEY, PUBLIC_BASE_URL
docker compose up -d --build
```
Put your own reverse proxy (nginx, etc.) in front of port 3000 for TLS and your public domain.
## Local development (Podman)
Requires `nixpacks` and a `docker` CLI pointed at your Podman socket (e.g. via the
`podman-docker` package) on your PATH.
```sh
npm install
DOCKER_SOCKET_PATH=/run/user/$(id -u)/podman/podman.sock \
DATA_DIR=./data MASTER_KEY=... SESSION_SECRET=... ADMIN_PASSWORD=... \
npm run dev:server
```
Because rootless Podman isolates the host network namespace from the container bridge,
the manager itself must run as a container attached to `mcp-net` to reach the MCP
containers it manages — running it directly on the host only works for routes that
don't need to reach a sibling container (e.g. `/health`, `/api/auth`).
## Known limitations
- Nixpacks needs a dependency manifest (`requirements.txt`/`pyproject.toml` for Python,
`package.json` for Node) in the installed repo. Repos without one will fail to build;
the full build log is always visible in the UI to diagnose this.
- No per-container resource limits, image garbage collection, or multi-user support yet.
- Builds run one at a time.

View File

@@ -8,13 +8,17 @@
"start": "node dist/index.js" "start": "node dist/index.js"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "11.10.0",
"dockerode": "4.0.9", "dockerode": "4.0.9",
"express": "4.21.2", "express": "4.21.2",
"express-session": "1.18.1",
"http-proxy": "1.18.1" "http-proxy": "1.18.1"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "7.6.13",
"@types/dockerode": "3.3.42", "@types/dockerode": "3.3.42",
"@types/express": "4.17.23", "@types/express": "4.17.23",
"@types/express-session": "1.18.2",
"@types/http-proxy": "1.17.16", "@types/http-proxy": "1.17.16",
"@types/node": "24.9.1", "@types/node": "24.9.1",
"tsx": "4.20.6", "tsx": "4.20.6",

View File

@@ -0,0 +1,50 @@
import crypto from "node:crypto";
import { Router } from "express";
import { config } from "../config/env.js";
export const authRouter = Router();
function passwordsMatch(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);
}
let failedAttempts = 0;
let lockoutUntil = 0;
authRouter.post("/login", (req, res) => {
if (Date.now() < lockoutUntil) {
res.status(429).json({ error: "too many attempts, try again later" });
return;
}
const { password } = req.body ?? {};
if (!config.adminPassword) {
res.status(500).json({ error: "ADMIN_PASSWORD is not configured" });
return;
}
if (typeof password !== "string" || !passwordsMatch(config.adminPassword, password)) {
failedAttempts += 1;
if (failedAttempts >= 5) {
lockoutUntil = Date.now() + 60_000;
failedAttempts = 0;
}
res.status(401).json({ error: "invalid password" });
return;
}
failedAttempts = 0;
req.session.authenticated = true;
res.json({ ok: true });
});
authRouter.post("/logout", (req, res) => {
req.session.destroy(() => res.json({ ok: true }));
});
authRouter.get("/session", (req, res) => {
res.json({ authenticated: Boolean(req.session.authenticated) });
});

View File

@@ -0,0 +1,136 @@
import { Router } from "express";
import { config } from "../config/env.js";
import {
type Installation,
listInstallations,
getInstallationById,
deleteInstallation,
updateInstallation,
} from "../db/installations.js";
import {
createDraftInstallation,
configureInstallation,
buildInstallation,
deployInstallation,
redeployInstallation,
removeInstallationContainer,
} from "../service/orchestrator.js";
import { buildLogPath } from "../build/nixpacks.js";
import { generateBearerToken } from "../crypto/secrets.js";
import fs from "node:fs";
export const installationsRouter = Router();
// Not masked: this API sits behind single-operator session auth (requireAuth), and the
// operator is the same person who owns these secrets — editing them requires seeing them.
function toPublicJson(installation: Installation) {
return {
...installation,
publicUrl: `${config.publicBaseUrl}/mcp/${installation.slug}`,
};
}
installationsRouter.get("/", (_req, res) => {
res.json(listInstallations().map(toPublicJson));
});
installationsRouter.get("/:id", (req, res) => {
const installation = getInstallationById(req.params.id);
if (!installation) {
res.status(404).json({ error: "not found" });
return;
}
res.json(toPublicJson(installation));
});
installationsRouter.post("/", async (req, res) => {
const { name, repoUrl, gitRef } = req.body ?? {};
if (!name || !repoUrl) {
res.status(400).json({ error: "name and repoUrl are required" });
return;
}
try {
const { installation, detectedEnvKeys } = await createDraftInstallation({ name, repoUrl, gitRef });
res.status(201).json({ installation: toPublicJson(installation), detectedEnvKeys });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
installationsRouter.patch("/:id/config", (req, res) => {
const { env, portEnvVar, hostEnvVar, httpPath, internalPort } = req.body ?? {};
try {
const installation = configureInstallation(req.params.id, { env, portEnvVar, hostEnvVar, httpPath, internalPort });
res.json(toPublicJson(installation));
} catch (err) {
res.status(404).json({ error: (err as Error).message });
}
});
installationsRouter.post("/:id/build", async (req, res) => {
try {
const installation = await buildInstallation(req.params.id);
res.json(toPublicJson(installation));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
installationsRouter.post("/:id/deploy", async (req, res) => {
try {
const installation = await deployInstallation(req.params.id);
res.json(toPublicJson(installation));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
installationsRouter.post("/:id/redeploy", async (req, res) => {
const rebuild = Boolean(req.body?.rebuild);
try {
const installation = await redeployInstallation(req.params.id, { rebuild });
res.json(toPublicJson(installation));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
installationsRouter.get("/:id/logs", (req, res) => {
const installation = getInstallationById(req.params.id);
if (!installation) {
res.status(404).json({ error: "not found" });
return;
}
const dir = `${config.logsDir}/${installation.slug}`;
if (!fs.existsSync(dir)) {
res.json({ log: "" });
return;
}
const files = fs.readdirSync(dir).sort();
const latest = files.at(-1);
if (!latest) {
res.json({ log: "" });
return;
}
res.json({ log: fs.readFileSync(buildLogPath(installation.slug, latest.replace(/\.log$/, "")), "utf8") });
});
installationsRouter.delete("/:id", async (req, res) => {
try {
await removeInstallationContainer(req.params.id);
deleteInstallation(req.params.id);
res.status(204).end();
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
installationsRouter.post("/:id/regenerate-token", (req, res) => {
try {
const installation = updateInstallation(req.params.id, { bearerToken: generateBearerToken() });
res.json({ bearerToken: installation.bearerToken });
} catch (err) {
res.status(404).json({ error: (err as Error).message });
}
});

View File

@@ -0,0 +1,46 @@
import fs from "node:fs";
import { spawn } from "node:child_process";
import path from "node:path";
import { config } from "../config/env.js";
function run(cmd: string, args: string[], cwd?: string): Promise<{ code: number; output: string }> {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { cwd });
let output = "";
child.stdout.on("data", (d) => (output += d.toString()));
child.stderr.on("data", (d) => (output += d.toString()));
child.on("error", reject);
child.on("close", (code) => resolve({ code: code ?? 1, output }));
});
}
export function repoDir(slug: string): string {
return path.join(config.reposDir, slug);
}
export async function cloneOrUpdateRepo(slug: string, repoUrl: string, gitRef?: string | null): Promise<string> {
const dir = repoDir(slug);
fs.mkdirSync(config.reposDir, { recursive: true });
if (fs.existsSync(path.join(dir, ".git"))) {
fs.rmSync(dir, { recursive: true, force: true });
}
const clone = await run("git", ["clone", "--depth", "1", ...(gitRef ? ["--branch", gitRef] : []), repoUrl, dir]);
if (clone.code !== 0) {
throw new Error(`git clone failed: ${clone.output}`);
}
return dir;
}
export function readEnvExampleKeys(dir: string): string[] {
const file = path.join(dir, ".env.example");
if (!fs.existsSync(file)) return [];
const content = fs.readFileSync(file, "utf8");
return content
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#") && line.includes("="))
.map((line) => line.split("=")[0]!.trim());
}

View File

@@ -0,0 +1,36 @@
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { config } from "../config/env.js";
export function buildLogPath(slug: string, buildId: string): string {
return path.join(config.logsDir, slug, `${buildId}.log`);
}
export interface BuildResult {
success: boolean;
logPath: string;
}
export function runNixpacksBuild(slug: string, repoDir: string, imageTag: string): Promise<BuildResult> {
return new Promise((resolve) => {
const buildId = Date.now().toString();
const logPath = buildLogPath(slug, buildId);
fs.mkdirSync(path.dirname(logPath), { recursive: true });
const logStream = fs.createWriteStream(logPath, { flags: "a" });
const child = spawn("nixpacks", ["build", repoDir, "--name", imageTag]);
child.stdout.pipe(logStream, { end: false });
child.stderr.pipe(logStream, { end: false });
child.on("close", (code) => {
logStream.end();
resolve({ success: code === 0, logPath });
});
child.on("error", (err) => {
logStream.write(`\nfailed to spawn nixpacks: ${err.message}\n`);
logStream.end();
resolve({ success: false, logPath });
});
});
}

View File

@@ -1,6 +1,17 @@
import path from "node:path";
const dataDir = process.env.DATA_DIR ?? path.resolve(process.cwd(), "../../data");
export const config = { export const config = {
port: Number(process.env.PORT ?? 3000), port: Number(process.env.PORT ?? 3000),
// Docker in prod; a rootless Podman socket (e.g. /run/user/<uid>/podman/podman.sock) in local dev. // 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", dockerSocketPath: process.env.DOCKER_SOCKET_PATH ?? "/var/run/docker.sock",
mcpNetworkName: process.env.MCP_NETWORK_NAME ?? "mcp-net", mcpNetworkName: process.env.MCP_NETWORK_NAME ?? "mcp-net",
dataDir,
dbPath: process.env.DB_PATH ?? path.join(dataDir, "db.sqlite"),
reposDir: path.join(dataDir, "repos"),
logsDir: path.join(dataDir, "logs"),
publicBaseUrl: process.env.PUBLIC_BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`,
adminPassword: process.env.ADMIN_PASSWORD,
sessionSecret: process.env.SESSION_SECRET,
}; };

View File

@@ -0,0 +1,31 @@
import crypto from "node:crypto";
function masterKey(): Buffer {
const raw = process.env.MASTER_KEY;
if (!raw || raw.length < 32) {
throw new Error("MASTER_KEY env var must be set to a string of at least 32 characters");
}
return crypto.createHash("sha256").update(raw).digest();
}
export function encryptSecret(plaintext: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", masterKey(), iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, ciphertext]).toString("base64");
}
export function decryptSecret(encoded: string): string {
const raw = Buffer.from(encoded, "base64");
const iv = raw.subarray(0, 12);
const authTag = raw.subarray(12, 28);
const ciphertext = raw.subarray(28);
const decipher = crypto.createDecipheriv("aes-256-gcm", masterKey(), iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
}
export function generateBearerToken(): string {
return crypto.randomBytes(32).toString("base64url");
}

View File

@@ -0,0 +1,32 @@
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { config } from "../config/env.js";
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
export const db = new Database(config.dbPath);
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS installations (
id TEXT PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
repo_url TEXT NOT NULL,
git_ref TEXT,
status TEXT NOT NULL,
status_detail TEXT,
image_tag TEXT,
container_id TEXT,
container_name TEXT NOT NULL,
internal_port INTEGER NOT NULL DEFAULT 8080,
http_path TEXT NOT NULL DEFAULT '/mcp',
port_env_var TEXT NOT NULL DEFAULT 'PORT',
host_env_var TEXT NOT NULL DEFAULT 'HOST',
bearer_token_encrypted TEXT,
env_json_encrypted TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);

View File

@@ -0,0 +1,192 @@
import crypto from "node:crypto";
import { db } from "./index.js";
import { encryptSecret, decryptSecret } from "../crypto/secrets.js";
export type InstallationStatus =
| "draft"
| "cloning"
| "building"
| "built"
| "starting"
| "running"
| "stopped"
| "error";
export interface Installation {
id: string;
slug: string;
name: string;
repoUrl: string;
gitRef: string | null;
status: InstallationStatus;
statusDetail: string | null;
imageTag: string | null;
containerId: string | null;
containerName: string;
internalPort: number;
httpPath: string;
portEnvVar: string;
hostEnvVar: string;
env: Record<string, string>;
bearerToken: string | null;
createdAt: string;
updatedAt: string;
}
interface Row {
id: string;
slug: string;
name: string;
repo_url: string;
git_ref: string | null;
status: InstallationStatus;
status_detail: string | null;
image_tag: string | null;
container_id: string | null;
container_name: string;
internal_port: number;
http_path: string;
port_env_var: string;
host_env_var: string;
bearer_token_encrypted: string | null;
env_json_encrypted: string | null;
created_at: string;
updated_at: string;
}
function rowToInstallation(row: Row): Installation {
return {
id: row.id,
slug: row.slug,
name: row.name,
repoUrl: row.repo_url,
gitRef: row.git_ref,
status: row.status,
statusDetail: row.status_detail,
imageTag: row.image_tag,
containerId: row.container_id,
containerName: row.container_name,
internalPort: row.internal_port,
httpPath: row.http_path,
portEnvVar: row.port_env_var,
hostEnvVar: row.host_env_var,
env: row.env_json_encrypted ? JSON.parse(decryptSecret(row.env_json_encrypted)) : {},
bearerToken: row.bearer_token_encrypted ? decryptSecret(row.bearer_token_encrypted) : null,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function slugify(name: string): string {
return name
.toLowerCase()
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
}
export function createInstallation(input: { name: string; slug: string; repoUrl: string; gitRef?: string }): Installation {
const id = crypto.randomUUID();
const now = new Date().toISOString();
db.prepare(
`INSERT INTO installations
(id, slug, name, repo_url, git_ref, status, container_name, created_at, updated_at)
VALUES (@id, @slug, @name, @repoUrl, @gitRef, 'draft', @containerName, @now, @now)`,
).run({
id,
slug: input.slug,
name: input.name,
repoUrl: input.repoUrl,
gitRef: input.gitRef ?? null,
containerName: `mcp-${input.slug}`,
now,
});
return getInstallationById(id)!;
}
export function getInstallationById(id: string): Installation | undefined {
const row = db.prepare("SELECT * FROM installations WHERE id = ?").get(id) as Row | undefined;
return row ? rowToInstallation(row) : undefined;
}
export function getInstallationBySlug(slug: string): Installation | undefined {
const row = db.prepare("SELECT * FROM installations WHERE slug = ?").get(slug) as Row | undefined;
return row ? rowToInstallation(row) : undefined;
}
export function listInstallations(): Installation[] {
const rows = db.prepare("SELECT * FROM installations ORDER BY created_at DESC").all() as Row[];
return rows.map(rowToInstallation);
}
export interface InstallationUpdate {
status?: InstallationStatus;
statusDetail?: string | null;
imageTag?: string | null;
containerId?: string | null;
internalPort?: number;
httpPath?: string;
portEnvVar?: string;
hostEnvVar?: string;
env?: Record<string, string>;
bearerToken?: string | null;
gitRef?: string | null;
}
export function updateInstallation(id: string, patch: InstallationUpdate): Installation {
const current = getInstallationById(id);
if (!current) throw new Error(`installation ${id} not found`);
const next = {
status: patch.status ?? current.status,
statusDetail: patch.statusDetail !== undefined ? patch.statusDetail : current.statusDetail,
imageTag: patch.imageTag !== undefined ? patch.imageTag : current.imageTag,
containerId: patch.containerId !== undefined ? patch.containerId : current.containerId,
internalPort: patch.internalPort ?? current.internalPort,
httpPath: patch.httpPath ?? current.httpPath,
portEnvVar: patch.portEnvVar ?? current.portEnvVar,
hostEnvVar: patch.hostEnvVar ?? current.hostEnvVar,
env: patch.env ?? current.env,
bearerToken: patch.bearerToken !== undefined ? patch.bearerToken : current.bearerToken,
gitRef: patch.gitRef !== undefined ? patch.gitRef : current.gitRef,
};
db.prepare(
`UPDATE installations SET
status = @status,
status_detail = @statusDetail,
image_tag = @imageTag,
container_id = @containerId,
internal_port = @internalPort,
http_path = @httpPath,
port_env_var = @portEnvVar,
host_env_var = @hostEnvVar,
env_json_encrypted = @envJsonEncrypted,
bearer_token_encrypted = @bearerTokenEncrypted,
git_ref = @gitRef,
updated_at = @updatedAt
WHERE id = @id`,
).run({
id,
status: next.status,
statusDetail: next.statusDetail,
imageTag: next.imageTag,
containerId: next.containerId,
internalPort: next.internalPort,
httpPath: next.httpPath,
portEnvVar: next.portEnvVar,
hostEnvVar: next.hostEnvVar,
envJsonEncrypted: encryptSecret(JSON.stringify(next.env)),
bearerTokenEncrypted: next.bearerToken ? encryptSecret(next.bearerToken) : null,
gitRef: next.gitRef,
updatedAt: new Date().toISOString(),
});
return getInstallationById(id)!;
}
export function deleteInstallation(id: string): void {
db.prepare("DELETE FROM installations WHERE id = ?").run(id);
}

View File

@@ -1,6 +1,7 @@
import net from "node:net";
import Docker from "dockerode"; import Docker from "dockerode";
import { config } from "../config/env.js"; import { config } from "../config/env.js";
import type { Installation } from "../installations.js"; import type { Installation } from "../db/installations.js";
const docker = new Docker({ socketPath: config.dockerSocketPath }); const docker = new Docker({ socketPath: config.dockerSocketPath });
@@ -11,15 +12,19 @@ export async function ensureNetwork(name: string): Promise<void> {
} }
} }
export async function runInstallationContainer(installation: Installation): Promise<void> { export async function removeContainerIfExists(containerName: string): Promise<void> {
await ensureNetwork(config.mcpNetworkName);
const existing = docker.getContainer(installation.containerName);
try { try {
await existing.remove({ force: true }); await docker.getContainer(containerName).remove({ force: true });
} catch { } catch {
// container didn't exist yet, nothing to clean up // didn't exist, nothing to clean up
} }
}
export async function runInstallationContainer(installation: Installation): Promise<string> {
if (!installation.imageTag) throw new Error("installation has no built image yet");
await ensureNetwork(config.mcpNetworkName);
await removeContainerIfExists(installation.containerName);
const env = { const env = {
...installation.env, ...installation.env,
@@ -38,6 +43,11 @@ export async function runInstallationContainer(installation: Installation): Prom
}); });
await container.start(); await container.start();
return container.id;
}
export async function stopInstallationContainer(containerName: string): Promise<void> {
await removeContainerIfExists(containerName);
} }
export async function getContainerIp(containerName: string): Promise<string> { export async function getContainerIp(containerName: string): Promise<string> {
@@ -49,3 +59,35 @@ export async function getContainerIp(containerName: string): Promise<string> {
} }
return network.IPAddress; return network.IPAddress;
} }
export async function waitForContainerHealth(
installation: Installation,
timeoutMs = 20_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: Error | undefined;
while (Date.now() < deadline) {
try {
const ip = await getContainerIp(installation.containerName);
await new Promise<void>((resolve, reject) => {
const socket = net.createConnection({ host: ip, port: installation.internalPort, timeout: 1000 });
socket.once("connect", () => {
socket.end();
resolve();
});
socket.once("error", reject);
socket.once("timeout", () => reject(new Error("connect timeout")));
});
return;
} catch (err) {
lastError = err as Error;
await new Promise((r) => setTimeout(r, 1000));
}
}
throw new Error(
`container did not start listening on ${installation.portEnvVar}=${installation.internalPort} within ${timeoutMs}ms` +
(lastError ? ` (last error: ${lastError.message})` : ""),
);
}

View File

@@ -1,13 +1,49 @@
import path from "node:path";
import express from "express"; import express from "express";
import session from "express-session";
import { config } from "./config/env.js"; import { config } from "./config/env.js";
import "./db/index.js";
import { handleMcpProxyRequest } from "./proxy/mcpProxy.js"; import { handleMcpProxyRequest } from "./proxy/mcpProxy.js";
import { authRouter } from "./api/auth.js";
import { installationsRouter } from "./api/installations.js";
import { requireAuth } from "./middleware/requireAuth.js";
if (!config.sessionSecret) {
throw new Error("SESSION_SECRET env var must be set");
}
const app = express(); const app = express();
app.set("trust proxy", 1);
app.use(express.json());
app.use(
session({
secret: config.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
maxAge: 1000 * 60 * 60 * 12,
},
}),
);
app.get("/health", (_req, res) => res.json({ ok: true })); app.get("/health", (_req, res) => res.json({ ok: true }));
// Bearer-token-gated MCP reverse proxy, public (auth handled per-installation).
app.all("/mcp/:slug", handleMcpProxyRequest); app.all("/mcp/:slug", handleMcpProxyRequest);
app.use("/api/auth", authRouter);
app.use("/api/installations", requireAuth, installationsRouter);
const webDist = path.resolve(import.meta.dirname, "../../web/dist");
app.use(express.static(webDist));
app.get(/^\/(?!api|mcp|health).*/, (_req, res) => {
res.sendFile(path.join(webDist, "index.html"), (err) => {
if (err) res.status(404).end();
});
});
app.listen(config.port, () => { app.listen(config.port, () => {
console.log(`remote-mcp-manager listening on http://localhost:${config.port}`); console.log(`remote-mcp-manager listening on http://localhost:${config.port}`);
}); });

View File

@@ -1,30 +0,0 @@
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);
}

View File

@@ -0,0 +1,9 @@
import type { Request, Response, NextFunction } from "express";
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
if (req.session.authenticated) {
next();
return;
}
res.status(401).json({ error: "authentication required" });
}

View File

@@ -1,12 +1,12 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import httpProxy from "http-proxy"; import httpProxy from "http-proxy";
import { findInstallationBySlug } from "../installations.js"; import { getInstallationBySlug } from "../db/installations.js";
import { getContainerIp } from "../docker/containers.js"; import { getContainerIp } from "../docker/containers.js";
const proxy = httpProxy.createProxyServer(); const proxy = httpProxy.createProxyServer();
proxy.on("error", (err, _req, res) => { proxy.on("error", (err, _req, res) => {
if (!res.headersSent) { if (!(res as Response).headersSent) {
(res as Response).status(502).json({ error: "proxy error", detail: err.message }); (res as Response).status(502).json({ error: "proxy error", detail: err.message });
} }
}); });
@@ -20,9 +20,9 @@ function tokensMatch(expected: string, provided: string): boolean {
export async function handleMcpProxyRequest(req: Request, res: Response): Promise<void> { export async function handleMcpProxyRequest(req: Request, res: Response): Promise<void> {
const { slug } = req.params; const { slug } = req.params;
const installation = findInstallationBySlug(slug); const installation = getInstallationBySlug(slug);
if (!installation) { if (!installation || installation.status !== "running" || !installation.bearerToken) {
res.status(404).json({ error: "unknown installation" }); res.status(404).json({ error: "unknown or unavailable installation" });
return; return;
} }

View File

@@ -0,0 +1,101 @@
import {
type Installation,
createInstallation,
updateInstallation,
getInstallationById,
getInstallationBySlug,
slugify,
} from "../db/installations.js";
import { cloneOrUpdateRepo, readEnvExampleKeys, repoDir } from "../build/git.js";
import { runNixpacksBuild } from "../build/nixpacks.js";
import { runInstallationContainer, stopInstallationContainer, waitForContainerHealth } from "../docker/containers.js";
import { generateBearerToken } from "../crypto/secrets.js";
export interface DraftResult {
installation: Installation;
detectedEnvKeys: string[];
}
export async function createDraftInstallation(input: {
name: string;
repoUrl: string;
gitRef?: string;
}): Promise<DraftResult> {
const slug = slugify(input.name);
if (getInstallationBySlug(slug)) {
throw new Error(`an installation with slug "${slug}" already exists`);
}
const installation = createInstallation({ name: input.name, slug, repoUrl: input.repoUrl, gitRef: input.gitRef });
try {
updateInstallation(installation.id, { status: "cloning" });
const dir = await cloneOrUpdateRepo(slug, input.repoUrl, input.gitRef);
const detectedEnvKeys = readEnvExampleKeys(dir);
const updated = updateInstallation(installation.id, { status: "draft", statusDetail: null });
return { installation: updated, detectedEnvKeys };
} catch (err) {
const updated = updateInstallation(installation.id, { status: "error", statusDetail: (err as Error).message });
return { installation: updated, detectedEnvKeys: [] };
}
}
export function configureInstallation(
id: string,
patch: {
env?: Record<string, string>;
portEnvVar?: string;
hostEnvVar?: string;
httpPath?: string;
internalPort?: number;
},
): Installation {
return updateInstallation(id, patch);
}
export async function buildInstallation(id: string): Promise<Installation> {
const installation = getInstallationById(id);
if (!installation) throw new Error("installation not found");
updateInstallation(id, { status: "building", statusDetail: null });
const imageTag = `mcpmgr/${installation.slug}:latest`;
const result = await runNixpacksBuild(installation.slug, repoDir(installation.slug), imageTag);
if (!result.success) {
return updateInstallation(id, {
status: "error",
statusDetail: `build failed, see log at ${result.logPath}`,
});
}
return updateInstallation(id, { status: "built", statusDetail: null, imageTag });
}
export async function deployInstallation(id: string): Promise<Installation> {
let installation = getInstallationById(id);
if (!installation) throw new Error("installation not found");
updateInstallation(id, { status: "starting", statusDetail: null });
try {
await runInstallationContainer(installation);
await waitForContainerHealth(installation);
const bearerToken = installation.bearerToken ?? generateBearerToken();
return updateInstallation(id, { status: "running", statusDetail: null, bearerToken });
} catch (err) {
return updateInstallation(id, { status: "error", statusDetail: (err as Error).message });
}
}
export async function redeployInstallation(id: string, opts: { rebuild?: boolean } = {}): Promise<Installation> {
if (opts.rebuild) {
await buildInstallation(id);
}
return deployInstallation(id);
}
export async function removeInstallationContainer(id: string): Promise<void> {
const installation = getInstallationById(id);
if (!installation) return;
await stopInstallationContainer(installation.containerName);
}

7
apps/server/src/types/session.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
import "express-session";
declare module "express-session" {
interface SessionData {
authenticated?: boolean;
}
}

12
apps/web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>remote-mcp-manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

22
apps/web/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "@remote-mcp-manager/web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.30.1"
},
"devDependencies": {
"@types/react": "18.3.26",
"@types/react-dom": "18.3.7",
"@vitejs/plugin-react": "4.3.4",
"typescript": "5.9.3",
"vite": "5.4.20"
}
}

28
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { useEffect, useState } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { api } from "./api/client";
import { Login } from "./pages/Login";
import { InstallationList } from "./pages/InstallationList";
import { InstallationDetail } from "./pages/InstallationDetail";
export function App() {
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
useEffect(() => {
api.session().then((s) => setAuthenticated(s.authenticated));
}, []);
if (authenticated === null) return null;
if (!authenticated) return <Login onLoggedIn={() => setAuthenticated(true)} />;
return (
<BrowserRouter>
<div style={{ maxWidth: 900, margin: "0 auto", padding: 16, fontFamily: "sans-serif" }}>
<Routes>
<Route path="/" element={<InstallationList />} />
<Route path="/installations/:id" element={<InstallationDetail />} />
</Routes>
</div>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,60 @@
export interface Installation {
id: string;
slug: string;
name: string;
repoUrl: string;
gitRef: string | null;
status: "draft" | "cloning" | "building" | "built" | "starting" | "running" | "stopped" | "error";
statusDetail: string | null;
imageTag: string | null;
containerName: string;
internalPort: number;
httpPath: string;
portEnvVar: string;
hostEnvVar: string;
env: Record<string, string>;
bearerToken: string | null;
publicUrl: string;
createdAt: string;
updatedAt: string;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error ?? `request failed with ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const api = {
session: () => request<{ authenticated: boolean }>("/api/auth/session"),
login: (password: string) => request<{ ok: true }>("/api/auth/login", { method: "POST", body: JSON.stringify({ password }) }),
logout: () => request<{ ok: true }>("/api/auth/logout", { method: "POST" }),
listInstallations: () => request<Installation[]>("/api/installations"),
getInstallation: (id: string) => request<Installation>(`/api/installations/${id}`),
createInstallation: (input: { name: string; repoUrl: string; gitRef?: string }) =>
request<{ installation: Installation; detectedEnvKeys: string[] }>("/api/installations", {
method: "POST",
body: JSON.stringify(input),
}),
configureInstallation: (
id: string,
patch: { env?: Record<string, string>; portEnvVar?: string; hostEnvVar?: string; httpPath?: string },
) => request<Installation>(`/api/installations/${id}/config`, { method: "PATCH", body: JSON.stringify(patch) }),
build: (id: string) => request<Installation>(`/api/installations/${id}/build`, { method: "POST" }),
deploy: (id: string) => request<Installation>(`/api/installations/${id}/deploy`, { method: "POST" }),
redeploy: (id: string, rebuild: boolean) =>
request<Installation>(`/api/installations/${id}/redeploy`, { method: "POST", body: JSON.stringify({ rebuild }) }),
regenerateToken: (id: string) =>
request<{ bearerToken: string }>(`/api/installations/${id}/regenerate-token`, { method: "POST" }),
remove: (id: string) => request<void>(`/api/installations/${id}`, { method: "DELETE" }),
logs: (id: string) => request<{ log: string }>(`/api/installations/${id}/logs`),
};

9
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,65 @@
import { useState } from "react";
import { api } from "../api/client";
export function InstallWizard({
onCreated,
onCancel,
}: {
onCreated: (id: string, detectedEnvKeys: string[]) => void;
onCancel: () => void;
}) {
const [name, setName] = useState("");
const [repoUrl, setRepoUrl] = useState("");
const [gitRef, setGitRef] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const { installation, detectedEnvKeys } = await api.createInstallation({ name, repoUrl, gitRef: gitRef || undefined });
onCreated(installation.id, detectedEnvKeys);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}
return (
<div style={{ border: "1px solid #ccc", padding: 16, marginBottom: 16 }}>
<h2>New installation</h2>
<form onSubmit={submit}>
<label>
Name
<input value={name} onChange={(e) => setName(e.target.value)} required style={{ display: "block", width: "100%" }} />
</label>
<label>
Repo URL
<input
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
placeholder="https://github.com/user/repo.git"
required
style={{ display: "block", width: "100%" }}
/>
</label>
<label>
Git ref (optional)
<input value={gitRef} onChange={(e) => setGitRef(e.target.value)} style={{ display: "block", width: "100%" }} />
</label>
<div style={{ marginTop: 8 }}>
<button type="submit" disabled={loading}>
{loading ? "Cloning…" : "Clone & continue"}
</button>{" "}
<button type="button" onClick={onCancel}>
Cancel
</button>
</div>
{error && <p style={{ color: "crimson" }}>{error}</p>}
</form>
</div>
);
}

View File

@@ -0,0 +1,211 @@
import { useEffect, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { api, type Installation } from "../api/client";
export function InstallationDetail() {
const { id } = useParams<{ id: string }>();
const location = useLocation();
const navigate = useNavigate();
const [installation, setInstallation] = useState<Installation | null>(null);
const [envRows, setEnvRows] = useState<[string, string][]>([]);
const [portEnvVar, setPortEnvVar] = useState("PORT");
const [hostEnvVar, setHostEnvVar] = useState("HOST");
const [httpPath, setHttpPath] = useState("/mcp");
const [log, setLog] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function refresh() {
if (!id) return;
const data = await api.getInstallation(id);
setInstallation(data);
setPortEnvVar(data.portEnvVar);
setHostEnvVar(data.hostEnvVar);
setHttpPath(data.httpPath);
const detectedEnvKeys = (location.state as { detectedEnvKeys?: string[] } | null)?.detectedEnvKeys ?? [];
const keys = new Set([...Object.keys(data.env), ...detectedEnvKeys]);
setEnvRows([...keys].map((k) => [k, data.env[k] ?? ""]));
}
useEffect(() => {
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
async function withBusy(label: string, fn: () => Promise<void>) {
setBusy(label);
setError(null);
try {
await fn();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(null);
}
}
if (!installation) return <p>Loading…</p>;
return (
<div>
<button onClick={() => navigate("/")}>← Back</button>
<h1>{installation.name}</h1>
<p>
Status: <strong>{installation.status}</strong>
{installation.statusDetail && <span style={{ color: "crimson" }}> — {installation.statusDetail}</span>}
</p>
{installation.status === "running" && installation.bearerToken && (
<div style={{ border: "1px solid #ccc", padding: 12, marginBottom: 16 }}>
<strong>Public URL:</strong> <code>{installation.publicUrl}</code>
<br />
<strong>Bearer token:</strong> <code>{installation.bearerToken}</code>{" "}
<button
onClick={() =>
withBusy("regen", async () => {
await api.regenerateToken(installation.id);
await refresh();
})
}
>
Regenerate token
</button>
</div>
)}
<h2>Configuration</h2>
<table>
<tbody>
{envRows.map(([k, v], idx) => (
<tr key={idx}>
<td>
<input
value={k}
onChange={(e) => {
const next = [...envRows];
next[idx] = [e.target.value, v];
setEnvRows(next);
}}
/>
</td>
<td>
<input
value={v}
onChange={(e) => {
const next = [...envRows];
next[idx] = [k, e.target.value];
setEnvRows(next);
}}
/>
</td>
<td>
<button onClick={() => setEnvRows(envRows.filter((_, i) => i !== idx))}>remove</button>
</td>
</tr>
))}
</tbody>
</table>
<button onClick={() => setEnvRows([...envRows, ["", ""]])}>Add env var</button>
<div style={{ marginTop: 12 }}>
<label>
Port env var name <input value={portEnvVar} onChange={(e) => setPortEnvVar(e.target.value)} />
</label>
<label style={{ marginLeft: 12 }}>
Host env var name <input value={hostEnvVar} onChange={(e) => setHostEnvVar(e.target.value)} />
</label>
<label style={{ marginLeft: 12 }}>
HTTP path <input value={httpPath} onChange={(e) => setHttpPath(e.target.value)} />
</label>
</div>
<div style={{ marginTop: 12 }}>
<button
disabled={busy !== null}
onClick={() =>
withBusy("config", async () => {
const env = Object.fromEntries(envRows.filter(([k]) => k.trim() !== ""));
await api.configureInstallation(installation.id, { env, portEnvVar, hostEnvVar, httpPath });
await refresh();
})
}
>
Save configuration
</button>
</div>
<h2>Actions</h2>
<button
disabled={busy !== null}
onClick={() =>
withBusy("build", async () => {
await api.build(installation.id);
await refresh();
})
}
>
{busy === "build" ? "Building…" : "Build"}
</button>{" "}
<button
disabled={busy !== null || installation.status === "cloning" || installation.status === "building" || installation.status === "starting"}
onClick={() =>
withBusy("deploy", async () => {
await api.deploy(installation.id);
await refresh();
})
}
>
{busy === "deploy" ? "Deploying…" : "Deploy"}
</button>{" "}
<button
disabled={busy !== null}
onClick={() =>
withBusy("redeploy", async () => {
await api.redeploy(installation.id, false);
await refresh();
})
}
>
Redeploy (env only)
</button>{" "}
<button
disabled={busy !== null}
onClick={() =>
withBusy("rebuild", async () => {
await api.redeploy(installation.id, true);
await refresh();
})
}
>
Rebuild & redeploy
</button>{" "}
<button
disabled={busy !== null}
onClick={() =>
withBusy("delete", async () => {
await api.remove(installation.id);
navigate("/");
})
}
>
Delete
</button>
{error && <p style={{ color: "crimson" }}>{error}</p>}
<h2>Build log</h2>
<button
onClick={() =>
withBusy("logs", async () => {
const { log } = await api.logs(installation.id);
setLog(log);
})
}
>
Refresh log
</button>
<pre style={{ background: "#111", color: "#0f0", padding: 12, maxHeight: 300, overflow: "auto" }}>{log}</pre>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { api, type Installation } from "../api/client";
import { InstallWizard } from "./InstallWizard";
export function InstallationList() {
const [installations, setInstallations] = useState<Installation[]>([]);
const [showWizard, setShowWizard] = useState(false);
const navigate = useNavigate();
async function refresh() {
setInstallations(await api.listInstallations());
}
useEffect(() => {
refresh();
}, []);
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h1>Installations</h1>
<button onClick={() => setShowWizard((v) => !v)}>{showWizard ? "Close" : "New installation"}</button>
</div>
{showWizard && (
<InstallWizard
onCancel={() => setShowWizard(false)}
onCreated={(id, detectedEnvKeys) => {
setShowWizard(false);
navigate(`/installations/${id}`, { state: { detectedEnvKeys } });
}}
/>
)}
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th align="left">Name</th>
<th align="left">Slug</th>
<th align="left">Status</th>
<th align="left">Repo</th>
</tr>
</thead>
<tbody>
{installations.map((i) => (
<tr key={i.id} style={{ borderTop: "1px solid #eee" }}>
<td>
<Link to={`/installations/${i.id}`}>{i.name}</Link>
</td>
<td>{i.slug}</td>
<td>{i.status}</td>
<td>{i.repoUrl}</td>
</tr>
))}
</tbody>
</table>
{installations.length === 0 && !showWizard && <p>No installations yet.</p>}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { useState } from "react";
import { api } from "../api/client";
export function Login({ onLoggedIn }: { onLoggedIn: () => void }) {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
await api.login(password);
onLoggedIn();
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}
return (
<div style={{ maxWidth: 320, margin: "10vh auto" }}>
<h1>remote-mcp-manager</h1>
<form onSubmit={submit}>
<input
type="password"
placeholder="Admin password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
style={{ width: "100%", padding: 8, marginBottom: 8 }}
/>
<button type="submit" disabled={loading} style={{ width: "100%", padding: 8 }}>
{loading ? "Signing in…" : "Sign in"}
</button>
{error && <p style={{ color: "crimson" }}>{error}</p>}
</form>
</div>
);
}

14
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}

11
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:3000",
},
},
});

24
docker-compose.yml Normal file
View File

@@ -0,0 +1,24 @@
services:
manager:
build: .
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
environment:
PORT: 3000
DOCKER_SOCKET_PATH: /var/run/docker.sock
MCP_NETWORK_NAME: mcp-net
DATA_DIR: /repo/data
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
SESSION_SECRET: ${SESSION_SECRET}
MASTER_KEY: ${MASTER_KEY}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/repo/data
networks:
- mcp-net
networks:
mcp-net:
name: mcp-net

2109
package-lock.json generated

File diff suppressed because it is too large Load Diff