diff --git a/frontend/app.py b/frontend/app.py
index 5118778..1557b4e 100644
--- a/frontend/app.py
+++ b/frontend/app.py
@@ -14,6 +14,11 @@
POST /api/extract-functions — parse Python code for async functions
GET /api/env — list .env vars (values masked)
POST /api/env — upsert vars into .env {vars: {KEY: VALUE}}
+GET /api/files — list a directory inside a mounted root (?root=&path=)
+POST /api/files/mkdir — create a directory {root, path}
+POST /api/files/upload — multipart upload (root, path, file)
+GET /api/files/download — download a file (?root=&path=)
+DELETE /api/files — delete a file/dir (?root=&path=&recursive=)
POST /api/restart — send SIGTERM to restart server
GET /api/config — UI feature flags (e.g. web_terminal)
WS /ws/terminal — interactive PTY terminal (optional ?cmd=…)
@@ -37,12 +42,21 @@
import textwrap
import threading
import traceback
-from pathlib import Path
+from pathlib import Path, PurePosixPath
from typing import Any
import yaml
-from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
-from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
+from fastapi import (
+ FastAPI,
+ File,
+ Form,
+ HTTPException,
+ Request,
+ UploadFile,
+ WebSocket,
+ WebSocketDisconnect,
+)
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
from config import CONFIG_DIR, ENV_FILE, FILES_DIR, REPOS_DIR
@@ -553,9 +567,48 @@ def _safe_local_openapi_path(source: str) -> str:
return str(candidate)
-def create_app(config_dir: Path | None = None, env_file: Path | None = None) -> "FastAPI":
+# ---------------------------------------------------------------------------
+# File manager helpers
+# ---------------------------------------------------------------------------
+#
+# The /api/files endpoints let the UI manage the volume-mounted directories
+# (tools, files, repos) — e.g. create /app/tools/secrets and upload
+# client_secret.json into it. Like the rest of the UI there is no auth: this
+# is intended for a trusted, single-user/local admin UI.
+
+MAX_UPLOAD_BYTES = int(os.environ.get("MCPPROXY_MAX_UPLOAD_BYTES", 50 * 1024 * 1024))
+
+
+def _resolve_in_root(roots: dict[str, Path], root: str, rel: str) -> Path:
+ """Resolve ``rel`` inside the whitelisted root, rejecting escapes.
+
+ Resolves symlinks before checking containment, so both ``..`` traversal
+ and symlinks pointing outside the root raise HTTPException(400).
+ """
+ base = roots.get(root)
+ if base is None:
+ raise HTTPException(400, f"Unknown root {root!r} (expected one of {sorted(roots)})")
+ base = base.resolve()
+ target = (base / (rel or "").lstrip("/")).resolve()
+ try:
+ target.relative_to(base)
+ except ValueError:
+ raise HTTPException(400, f"Path escapes the {root} directory: {rel!r}")
+ return target
+
+
+def create_app(
+ config_dir: Path | None = None,
+ env_file: Path | None = None,
+ file_roots: dict[str, Path] | None = None,
+) -> "FastAPI":
_config_dir = config_dir or CONFIG_DIR
_env_file = env_file or ENV_FILE
+ _file_roots = file_roots or {
+ "tools": _config_dir,
+ "files": FILES_DIR,
+ "repos": REPOS_DIR,
+ }
app = FastAPI(title="mcpproxy UI", docs_url=None, redoc_url=None)
@@ -938,6 +991,133 @@ async def set_env(request: Request) -> dict:
_write_env_file(_env_file, updates)
return {"ok": True, "written": list(updates.keys())}
+ # ── File manager ─────────────────────────────────────────────────────────
+ #
+ # Browse / mkdir / upload / download / delete inside the volume-mounted
+ # roots (tools, files, repos). Paths are always relative to a whitelisted
+ # root and validated with resolve()+relative_to() — see _resolve_in_root.
+
+ @app.get("/api/files")
+ async def list_dir(root: str = "tools", path: str = "") -> dict:
+ target = _resolve_in_root(_file_roots, root, path)
+ entries: list[dict[str, Any]] = []
+ if target.is_dir():
+ base = _file_roots[root].resolve()
+ for entry in target.iterdir():
+ if entry.is_symlink():
+ etype = "symlink"
+ elif entry.is_dir():
+ etype = "directory"
+ else:
+ etype = "file"
+ try:
+ stat = entry.stat() if etype != "symlink" else entry.lstat()
+ size = stat.st_size if etype == "file" else 0
+ mtime = stat.st_mtime
+ except OSError:
+ size, mtime = 0, 0
+ entries.append({
+ "name": entry.name,
+ "path": entry.relative_to(base).as_posix(),
+ "type": etype,
+ "size": size,
+ "mtime": mtime,
+ })
+ entries.sort(key=lambda e: (e["type"] != "directory", e["name"].lower()))
+ return {
+ "ok": True,
+ "root": root,
+ "path": path,
+ "roots": sorted(_file_roots),
+ "entries": entries,
+ }
+
+ @app.post("/api/files/mkdir")
+ async def make_dir(request: Request) -> dict:
+ body = await request.json()
+ root = (body.get("root") or "tools").strip()
+ rel = (body.get("path") or "").strip()
+ if not rel.strip("/"):
+ raise HTTPException(400, "path is required")
+ target = _resolve_in_root(_file_roots, root, rel)
+ if target.exists() and not target.is_dir():
+ raise HTTPException(400, f"A file already exists at {rel!r}")
+ target.mkdir(parents=True, exist_ok=True)
+ return {"ok": True, "path": target.relative_to(_file_roots[root].resolve()).as_posix()}
+
+ @app.post("/api/files/upload")
+ async def upload_file(
+ root: str = Form("tools"),
+ path: str = Form(""),
+ file: UploadFile = File(...),
+ ) -> dict:
+ """Upload a file into ``path`` (a directory) under the given root.
+
+ The client-supplied filename is reduced to its basename, so it cannot
+ carry path segments. Existing files are overwritten — this is a
+ trusted single-admin UI and re-uploading a corrected file is the
+ common case.
+ """
+ name = Path(file.filename or "").name
+ if not name or name in (".", ".."):
+ raise HTTPException(400, "Invalid filename")
+ target_dir = _resolve_in_root(_file_roots, root, path)
+ if target_dir.exists() and not target_dir.is_dir():
+ raise HTTPException(400, f"Upload target {path!r} is not a directory")
+ target_dir.mkdir(parents=True, exist_ok=True)
+ target = target_dir / name
+ size = 0
+ try:
+ with target.open("wb") as out:
+ while chunk := await file.read(1024 * 1024):
+ size += len(chunk)
+ if size > MAX_UPLOAD_BYTES:
+ raise HTTPException(
+ 413, f"File exceeds the upload limit ({MAX_UPLOAD_BYTES} bytes)"
+ )
+ out.write(chunk)
+ except HTTPException:
+ target.unlink(missing_ok=True)
+ raise
+ rel = target.relative_to(_file_roots[root].resolve()).as_posix()
+ return {"ok": True, "path": rel, "size": size}
+
+ @app.get("/api/files/download")
+ async def download_file(root: str = "tools", path: str = "") -> FileResponse:
+ target = _resolve_in_root(_file_roots, root, path)
+ if not target.is_file():
+ raise HTTPException(404, f"File not found: {path!r}")
+ return FileResponse(target, filename=target.name)
+
+ @app.delete("/api/files")
+ async def delete_path(root: str = "tools", path: str = "", recursive: bool = False) -> dict:
+ rel = PurePosixPath(path.strip("/"))
+ if not rel.name or rel.name in (".", ".."):
+ raise HTTPException(400, "path is required (cannot delete the root itself)")
+ # Validate the parent directory, then lstat the final component: a
+ # symlink pointing outside the root would fail _resolve_in_root, but
+ # deleting the link itself is safe — remove it without following it.
+ parent = _resolve_in_root(_file_roots, root, rel.parent.as_posix())
+ target = parent / rel.name
+ if target.is_symlink():
+ target.unlink()
+ return {"ok": True}
+ if not target.exists():
+ raise HTTPException(404, f"Not found: {path!r}")
+ if target.is_dir():
+ if recursive:
+ shutil.rmtree(target)
+ else:
+ try:
+ target.rmdir()
+ except OSError:
+ raise HTTPException(
+ 400, f"Directory {path!r} is not empty (pass recursive=true)"
+ )
+ else:
+ target.unlink()
+ return {"ok": True}
+
# ── OpenAI-compatible tool endpoints ─────────────────────────────────────
#
# These endpoints let OpenAI-style callers (e.g. OpenWebUI tool servers)
@@ -1277,6 +1457,10 @@ async def index():
+
+
MCP :8888 | UI :8889
@@ -1826,6 +2010,67 @@ async def index():
+
+
+
+
+
+
📁 Files
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ These directories are volume mounts inside the container (e.g. /app/tools) —
+ a good place for provider configs and credential files like
+ tools/secrets/client_secret.json.
+
+
+
+
+
+
+
+
+
+
+
+
🧪 Test Tools
+
+
+
+
+
+
+
+
+
+
+
Select a tool to test it.
+
+
+
+
+
+
+
+
@@ -1839,6 +2084,9 @@ async def index():
let currentProvider = null; // the structured JSON object being edited
let codeEditor = null; // CodeMirror instance for the code block
let secretsModal = null, wizModal = null, termModal = null;
+let filesModal = null, ttModal = null;
+let filesRoot = 'tools', filesPath = '', filesRoots = ['tools', 'files', 'repos'];
+let ttTools = [], ttSelected = null; // tool tester: /v1/tools entries + selected name
let webTerminalEnabled = false;
let term = null, termFit = null, termSock = null; // xterm.js terminal state
let wzType = null; // 'code' | 'package' | 'repository' | 'remote' | 'rest'
@@ -1875,7 +2123,11 @@ async def index():
secretsModal = new bootstrap.Modal('#secrets-modal');
wizModal = new bootstrap.Modal('#wizard-modal');
termModal = new bootstrap.Modal('#terminal-modal');
+ filesModal = new bootstrap.Modal('#files-modal');
+ ttModal = new bootstrap.Modal('#tooltest-modal');
document.getElementById('terminal-modal').addEventListener('hidden.bs.modal', closeTerminal);
+ document.getElementById('files-list').addEventListener('click', filesListClick);
+ document.getElementById('tt-list').addEventListener('click', ttListClick);
// Wizard: live function detection as the user types into the code textarea
document.getElementById('wz-code-input').addEventListener('input', () => {
clearTimeout(_wzAnalyzeDebounce);
@@ -3682,6 +3934,320 @@ async def index():
if (currentName) await openProvider(currentName);
}
+// ─────────────────────────────────────────────────────────────────────────────
+// File manager (/api/files — browse / mkdir / upload / download / delete)
+// ─────────────────────────────────────────────────────────────────────────────
+async function openFiles() {
+ filesModal.show();
+ await filesRefresh();
+}
+
+function filesSetRoot(root) {
+ filesRoot = root;
+ filesPath = '';
+ filesRefresh();
+}
+
+function filesNavigate(path) {
+ filesPath = path;
+ filesRefresh();
+}
+
+function _fmtSize(n) {
+ if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB';
+ if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
+ return n + ' B';
+}
+
+async function filesRefresh() {
+ let data;
+ try {
+ data = await api('GET', `/api/files?root=${encodeURIComponent(filesRoot)}&path=${encodeURIComponent(filesPath)}`);
+ } catch (e) { toast(e.message, false); return; }
+ filesRoots = data.roots;
+
+ const sel = document.getElementById('files-root');
+ sel.innerHTML = filesRoots.map(r =>
+ ``).join('');
+
+ // Breadcrumb: root + each path segment is clickable.
+ const segs = filesPath ? filesPath.split('/') : [];
+ let crumbs = `