feat(serve): turnkey secure remote MCP server (#1877) (#1900)

* feat(serve): turnkey secure remote MCP server (#1877)

Add `mempalace serve`: a secure-by-default wrapper over the HTTP MCP
transport so a team can stand up a shared central palace with one
command.

Server capabilities (mempalace/mcp_server.py):
- Native TLS via --tls-cert/--tls-key (env MEMPALACE_MCP_TLS_CERT/_KEY):
  wraps the socket in a TLS 1.2+ context, validated before bind. Token
  is still required on a non-loopback bind (TLS != auth).
- Read-only mode via --read-only (env MEMPALACE_MCP_READ_ONLY): the 24
  mutating tools are hidden from tools/list and refused at dispatch
  (-32003), enforced before arg handling — not merely hidden.

Turnkey command (mempalace/cli.py):
- Auto-generates a strong bearer token for non-loopback binds, stored
  0600 under ~/.mempalace/server/ and printed once; reused across
  restarts. Token rides in the child env, never argv, so it can't leak
  via ps.
- Prints a ready-to-paste client config (scheme reflects TLS), then
  foreground-execs the real server so Docker/systemd own the lifecycle.

Deployment (deploy/):
- docker-compose.server.yml wires the server + Qdrant with a /healthz
  healthcheck and persistent volumes.
- server.env.example documents the env surface.
- mempalace-server.service is a hardened systemd unit template.

Tests: TLS handshake (openssl-gated), read-only enforcement, token
autogen/0600/reuse, token-not-in-argv, secure-by-default gates.

Docs: remote-server guide now leads with `mempalace serve` plus Compose
and systemd subsections.

* test(serve): fix Windows — don't patch os.name; gate 0600 asserts to POSIX

Patching os.name to 'posix' broke Path.home() on Windows (pathlib mixed
POSIX home resolution with Windows drive parsing). Capture both exec
branches (os.execve + subprocess.run) instead, and guard the POSIX
permission-bit assertions behind os.name == 'posix' (Windows files
report 0o666).
This commit is contained in:
Igor Lins e Silva 2026-06-29 03:28:06 -03:00 committed by GitHub
parent 8ec284db5d
commit afd0428823
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 760 additions and 19 deletions

View File

@ -0,0 +1,71 @@
# MemPalace remote team server — MCP over HTTP, backed by a central Qdrant.
#
# One command stands up a shared memory service a whole team's AI clients
# connect to. Embeddings are still produced locally inside the mempalace
# container; only your own Qdrant ever receives the vectors and text.
#
# 1. cp deploy/server.env.example deploy/.env && edit deploy/.env
# (at minimum set MEMPALACE_MCP_HTTP_TOKEN to a long random secret)
# 2. docker compose -f deploy/docker-compose.server.yml --env-file deploy/.env up -d
# 3. connect a client (see the Remote / Team Server guide):
# claude mcp add --transport http mempalace http://YOUR_HOST:8765/mcp \
# --header "Authorization: Bearer $MEMPALACE_MCP_HTTP_TOKEN"
#
# SECURITY: this exposes plaintext HTTP on :8765. For anything beyond a trusted
# private network, put a TLS-terminating reverse proxy in front (nginx/Caddy/
# Traefik) and only expose the proxy. The bearer token is mandatory for the
# network-exposed (0.0.0.0) bind.
services:
qdrant:
image: qdrant/qdrant:latest
restart: unless-stopped
volumes:
- qdrant-storage:/qdrant/storage
# Not published to the host: only the mempalace service reaches it over the
# internal compose network. Uncomment to inspect Qdrant directly.
# ports:
# - "6333:6333"
mempalace:
image: ghcr.io/mempalace/mempalace:latest
restart: unless-stopped
depends_on:
- qdrant
command:
- serve
- --host
- "0.0.0.0"
- --port
- "8765"
- --backend
- qdrant
# Uncomment to expose recall without write access to most clients:
# - --read-only
environment:
# Required for the network-exposed bind. Set in deploy/.env.
MEMPALACE_MCP_HTTP_TOKEN: ${MEMPALACE_MCP_HTTP_TOKEN:?set MEMPALACE_MCP_HTTP_TOKEN in deploy/.env}
MEMPALACE_QDRANT_URL: http://qdrant:6333
MEMPALACE_QDRANT_API_KEY: ${MEMPALACE_QDRANT_API_KEY:-}
# Set to cuda/dml/coreml on an accelerated host (see Dockerfile.gpu).
MEMPALACE_EMBEDDING_DEVICE: ${MEMPALACE_EMBEDDING_DEVICE:-auto}
ports:
- "8765:8765"
volumes:
- mempalace-data:/data
healthcheck:
# The image has no curl; use Python (always present). /healthz needs no auth.
# If you enable TLS on the server itself, switch this to https + ssl context.
test:
- CMD
- python
- -c
- "import urllib.request,sys; sys.exit(0) if urllib.request.urlopen('http://127.0.0.1:8765/healthz').read().strip()==b'ok' else sys.exit(1)"
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
volumes:
qdrant-storage:
mempalace-data:

View File

@ -0,0 +1,48 @@
# MemPalace remote MCP server — systemd unit template.
#
# Install:
# sudo useradd --system --home /var/lib/mempalace --shell /usr/sbin/nologin mempalace
# sudo install -d -o mempalace -g mempalace -m 750 /var/lib/mempalace /etc/mempalace
# sudo cp deploy/server.env.example /etc/mempalace/server.env # then edit + chmod 600
# sudo install -m 600 -o mempalace -g mempalace /etc/mempalace/server.env /etc/mempalace/server.env
# # install mempalace into a venv on PATH, or adjust ExecStart to its absolute path
# sudo cp deploy/mempalace-server.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now mempalace-server
#
# This binds 0.0.0.0:8765 and requires MEMPALACE_MCP_HTTP_TOKEN (set in the
# EnvironmentFile). Front it with a TLS-terminating reverse proxy, or set
# MEMPALACE_MCP_TLS_CERT / _KEY in the EnvironmentFile for native TLS.
[Unit]
Description=MemPalace remote MCP server
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=mempalace
Group=mempalace
EnvironmentFile=/etc/mempalace/server.env
ExecStart=mempalace serve --host 0.0.0.0 --port 8765
Restart=on-failure
RestartSec=2
# --- Hardening ---------------------------------------------------------------
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=false
# The palace and any local state live here; everything else is read-only.
ReadWritePaths=/var/lib/mempalace
StateDirectory=mempalace
[Install]
WantedBy=multi-user.target

28
deploy/server.env.example Normal file
View File

@ -0,0 +1,28 @@
# MemPalace remote server environment.
# Copy to deploy/.env (compose) or /etc/mempalace/server.env (systemd) and edit.
# Keep this file readable only by the service account: chmod 600.
# --- Required for a network-exposed (0.0.0.0) bind ------------------------------
# Clients send: Authorization: Bearer <this value>. Generate a strong secret:
# openssl rand -hex 32
MEMPALACE_MCP_HTTP_TOKEN=
# --- Storage backend ------------------------------------------------------------
# The team server should use a networked backend so the palace is shared.
MEMPALACE_BACKEND=qdrant
MEMPALACE_QDRANT_URL=http://qdrant:6333
# MEMPALACE_QDRANT_API_KEY=
# --- Embedding ------------------------------------------------------------------
# auto | cpu | cuda | dml | coreml. Use cuda on a GPU host (needs the GPU image).
MEMPALACE_EMBEDDING_DEVICE=auto
# --- Optional: native TLS (otherwise terminate TLS at a reverse proxy) ----------
# Point these at a PEM cert/key the service account can read. When set, serve
# speaks HTTPS directly and clients connect to https://...
# MEMPALACE_MCP_TLS_CERT=/etc/mempalace/tls/cert.pem
# MEMPALACE_MCP_TLS_KEY=/etc/mempalace/tls/key.pem
# --- Palace location (systemd / bare-metal) -------------------------------------
# In Docker the palace lives on the mempalace-data volume by default.
# MEMPALACE_PALACE_PATH=/var/lib/mempalace/palace

View File

@ -1354,6 +1354,154 @@ def cmd_mcp(args):
print(f" {base_server_cmd} --palace /path/to/palace")
_SERVER_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1", "[::1]"}
_SERVER_BIND_ALL_HOSTS = {"0.0.0.0", "::", "[::]"}
def _server_is_loopback(host: str) -> bool:
return (host or "").strip().lower() in _SERVER_LOOPBACK_HOSTS
def _server_token_path(palace_path: str) -> Path:
"""Per-palace location for the auto-generated server bearer token.
Distinct from the daemon's token dir; keyed by the canonical palace path so
one server per palace reuses a stable token across restarts.
"""
import hashlib
canonical = os.path.abspath(os.path.realpath(os.path.expanduser(palace_path)))
key = hashlib.sha256(os.path.normcase(canonical).encode("utf-8")).hexdigest()[:24]
return Path.home() / ".mempalace" / "server" / key / "token"
def _load_or_create_server_token(palace_path: str) -> tuple[str, bool]:
"""Return (token, created). Reuse an existing 0600 token or mint a new one."""
import secrets
token_path = _server_token_path(palace_path)
if token_path.exists():
existing = token_path.read_text(encoding="utf-8").strip()
if existing:
return existing, False
token = secrets.token_urlsafe(32)
token_path.parent.mkdir(parents=True, exist_ok=True)
try:
os.chmod(str(token_path.parent), 0o700)
except OSError:
pass
# O_CREAT with 0600 so the token is never briefly world-readable on disk.
fd = os.open(str(token_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(token + "\n")
return token, True
def cmd_serve(args):
"""Run a secure remote HTTP MCP server for a team to share one palace (#1877).
A turnkey wrapper over ``mempalace-mcp --transport http``: it resolves a
bearer token (auto-generating a strong one for non-loopback binds), prints a
ready-to-paste client config, then execs the real server in the foreground so
Docker/systemd own the process lifecycle. The token is passed via the
environment, never argv, so it can't leak through ``ps``.
"""
host = args.host
port = int(args.port)
loopback = _server_is_loopback(host)
palace_path = (
os.path.abspath(os.path.expanduser(args.palace))
if args.palace
else MempalaceConfig().palace_path
)
backend = _backend_arg(args)
tls_cert = os.path.expanduser(args.tls_cert) if args.tls_cert else None
tls_key = os.path.expanduser(args.tls_key) if args.tls_key else None
if bool(tls_cert) != bool(tls_key):
print("mempalace: --tls-cert and --tls-key must be given together", file=sys.stderr)
sys.exit(2)
for label, path in (("--tls-cert", tls_cert), ("--tls-key", tls_key)):
if path and not os.path.isfile(path):
print(f"mempalace: {label} file not found: {path}", file=sys.stderr)
sys.exit(2)
scheme = "https" if tls_cert else "http"
# Token resolution. Explicit flag > existing env > (non-loopback) auto-generated.
token = (args.token or os.environ.get("MEMPALACE_MCP_HTTP_TOKEN", "")).strip()
token_created = False
if not token and not loopback and not args.allow_insecure:
token, token_created = _load_or_create_server_token(palace_path)
# Build the child environment. Token rides in the env (never argv) so it
# stays out of the process table.
env = dict(os.environ)
env["MEMPALACE_PALACE_PATH"] = palace_path
if backend:
env["MEMPALACE_BACKEND"] = str(backend).strip().lower()
if token:
env["MEMPALACE_MCP_HTTP_TOKEN"] = token
if args.allow_insecure:
env["MEMPALACE_MCP_HTTP_ALLOW_INSECURE_NO_TOKEN"] = "1"
child = [
sys.executable,
"-m",
"mempalace.mcp_server",
"--transport",
"http",
"--host",
host,
"--port",
str(port),
]
if backend:
child += ["--backend", str(backend).strip().lower()]
child += ["--palace", palace_path]
if tls_cert:
child += ["--tls-cert", tls_cert, "--tls-key", tls_key]
if args.read_only:
child.append("--read-only")
# Client-facing address: 0.0.0.0/:: means "all interfaces" — clients dial a
# real reachable host, so show a placeholder rather than the bind wildcard.
client_host = "YOUR_SERVER_HOST" if host.strip().lower() in _SERVER_BIND_ALL_HOSTS else host
url = f"{scheme}://{client_host}:{port}/mcp"
print("Starting MemPalace remote MCP server")
print(f" palace : {palace_path}")
print(f" backend : {(backend or 'default').strip().lower() if backend else 'default'}")
print(f" bind : {host}:{port} ({'loopback' if loopback else 'network-exposed'})")
print(f" tls : {'on' if tls_cert else 'off (plaintext — terminate TLS at a proxy)'}")
print(f" read-only: {'yes' if args.read_only else 'no'}")
if token_created:
print("\n A new bearer token was generated and stored 0600 at:")
print(f" {_server_token_path(palace_path)}")
print(" Store it securely — clients need it to connect:")
print(f" {token}")
print("\nConnect a client:")
if token:
print(
f" claude mcp add --transport http mempalace {url} "
f'--header "Authorization: Bearer {token if token_created else "$MEMPALACE_MCP_HTTP_TOKEN"}"'
)
else:
print(f" claude mcp add --transport http mempalace {url}")
print(f" curl {scheme}://{client_host}:{port}/healthz # liveness (no auth)\n")
sys.stdout.flush()
# Foreground: hand the process to the real server so signals (SIGTERM from
# Docker/systemd) reach it directly. exec on POSIX; subprocess on Windows
# (no exec semantics) propagating the exit code.
if os.name == "posix":
os.execve(sys.executable, child, env)
else:
import subprocess
completed = subprocess.run(child, env=env)
sys.exit(completed.returncode)
def cmd_compress(args):
"""Compress drawers in a wing using AAAK Dialect."""
from .dialect import Dialect
@ -1965,6 +2113,38 @@ def main():
help="Storage backend to include in the MCP startup command",
)
# serve — turnkey remote HTTP MCP server (#1877)
p_serve = sub.add_parser(
"serve",
help="Run a secure remote HTTP MCP server for a team to share one palace",
)
p_serve.add_argument(
"--host", default="127.0.0.1", help="Bind address (use 0.0.0.0 for remote clients)"
)
p_serve.add_argument("--port", type=int, default=8765, help="Bind port (default: 8765)")
p_serve.add_argument(
"--backend", default=None, help="Storage backend (default: config/env/detected)"
)
p_serve.add_argument("--palace", default=None, help="Palace path (overrides config/env)")
p_serve.add_argument(
"--token",
default=None,
help="Bearer token clients must present. Default: reuse/auto-generate one for "
"non-loopback binds (stored 0600 under ~/.mempalace/server/).",
)
p_serve.add_argument("--tls-cert", default=None, help="PEM certificate to enable TLS")
p_serve.add_argument("--tls-key", default=None, help="PEM private key matching --tls-cert")
p_serve.add_argument(
"--read-only",
action="store_true",
help="Expose recall only: mutating tools are hidden and refused",
)
p_serve.add_argument(
"--allow-insecure",
action="store_true",
help="Permit a non-loopback bind with no token (only behind a trusted proxy)",
)
# status
# migrate
p_migrate = sub.add_parser(
@ -2073,6 +2253,7 @@ def main():
"sweep": cmd_sweep,
"sync": cmd_sync,
"mcp": cmd_mcp,
"serve": cmd_serve,
"compress": cmd_compress,
"wake-up": cmd_wakeup,
"repair": cmd_repair,

View File

@ -275,6 +275,23 @@ def _parse_args():
default=8765,
help="HTTP port to bind when --transport=http (default: 8765)",
)
parser.add_argument(
"--tls-cert",
metavar="PATH",
help="PEM certificate to terminate TLS on the HTTP transport "
"(requires --tls-key; env MEMPALACE_MCP_TLS_CERT)",
)
parser.add_argument(
"--tls-key",
metavar="PATH",
help="PEM private key matching --tls-cert (env MEMPALACE_MCP_TLS_KEY)",
)
parser.add_argument(
"--read-only",
action="store_true",
help="Serve a read-only tool surface: the mutating tools are hidden from "
"tools/list and refused at dispatch (env MEMPALACE_MCP_READ_ONLY)",
)
args, unknown = parser.parse_known_args()
if unknown:
logger.debug("Ignoring unknown args: %s", unknown)
@ -295,6 +312,14 @@ if _args.backend:
_config = MempalaceConfig()
# Read-only server mode: when on, the mutating tools are hidden from tools/list
# and refused at dispatch (-32003). Resolved once at startup from --read-only or
# MEMPALACE_MCP_READ_ONLY. Computed inline (not via _truthy_env, defined below)
# so it is available to the request path regardless of import order.
_READ_ONLY = bool(getattr(_args, "read_only", False)) or os.environ.get(
"MEMPALACE_MCP_READ_ONLY", ""
).strip().lower() in {"1", "true", "yes", "on"}
_kg_by_path: dict[str, KnowledgeGraph] = {}
_kg_cache_lock = threading.Lock()
_palace_flag_given: bool = bool(_args.palace)
@ -4426,9 +4451,36 @@ def _internal_tool_error(req_id, tool_name: str, exc: BaseException = None) -> d
}
def _mcp_read_only_refusal(req_id, tool_name: str):
"""Refuse mutating tools when the server runs in read-only mode (#1877).
Read-only is an operator-set server mode (``--read-only`` /
``MEMPALACE_MCP_READ_ONLY``), distinct from the dynamic peer-writer lock:
it is an unconditional gate so a shared team server can expose recall
without write access. Enforced at dispatch, not merely hidden from
tools/list, so a client that calls a mutating tool by name is still refused.
"""
if not _READ_ONLY or tool_name not in _MUTATING_TOOLS:
return None
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {
"code": -32003,
"message": "Server is in read-only mode; this tool is disabled",
"data": {"tool": tool_name},
},
}
def _mcp_tool_preflight_refusal(req_id, tool_name: str):
"""Run MCP request preflight gates outside handle_request complexity."""
read_only_error = _mcp_read_only_refusal(req_id, tool_name)
if read_only_error is not None:
return read_only_error
sqlite_integrity_error = _mcp_sqlite_integrity_refusal(req_id, tool_name)
if sqlite_integrity_error is not None:
return sqlite_integrity_error
@ -4480,6 +4532,8 @@ def handle_request(request):
# Notifications (no id) never get a response per JSON-RPC spec
return None
elif method == "tools/list":
# In read-only mode, hide the mutating tools so clients don't advertise
# write capabilities they can't use (dispatch also refuses them, #1877).
return {
"jsonrpc": "2.0",
"id": req_id,
@ -4487,6 +4541,7 @@ def handle_request(request):
"tools": [
{"name": n, "description": t["description"], "inputSchema": t["input_schema"]}
for n, t in TOOLS.items()
if not (_READ_ONLY and n in _MUTATING_TOOLS)
]
},
}
@ -4856,6 +4911,37 @@ _HTTP_LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "::1", "[::1]")
_HTTP_ALLOW_INSECURE_NO_TOKEN_ENV = "MEMPALACE_MCP_HTTP_ALLOW_INSECURE_NO_TOKEN"
def _resolve_tls_paths() -> tuple:
"""Resolve the TLS cert/key from --tls-cert/--tls-key or env, or (None, None).
Flags take precedence over ``MEMPALACE_MCP_TLS_CERT`` / ``MEMPALACE_MCP_TLS_KEY``.
Both must be given together; one without the other is a configuration error
(raised here, before any bind, so it fails loudly at startup).
"""
cert = (
getattr(_args, "tls_cert", None) or os.environ.get("MEMPALACE_MCP_TLS_CERT", "")
).strip()
key = (getattr(_args, "tls_key", None) or os.environ.get("MEMPALACE_MCP_TLS_KEY", "")).strip()
if bool(cert) != bool(key):
raise ValueError("TLS requires both --tls-cert and --tls-key (or the matching env vars)")
if not cert:
return None, None
for label, path in (("--tls-cert", cert), ("--tls-key", key)):
if not os.path.isfile(path):
raise ValueError(f"{label} file not found: {path!r}")
return cert, key
def _wrap_tls(sock, cert: str, key: str):
"""Wrap a server socket in a TLS 1.2+ context. Raises on bad cert/key."""
import ssl
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.load_cert_chain(certfile=cert, keyfile=key)
return ctx.wrap_socket(sock, server_side=True)
def _http_is_loopback(host: str) -> bool:
"""Whether ``host`` binds only to this machine."""
return (host or "").strip().lower() in _HTTP_LOOPBACK_HOSTS
@ -4921,6 +5007,11 @@ def _build_http_server(host: str, port: int):
"when a trusted fronting layer provides access control."
)
# Resolve TLS before bind so a bad cert/key fails loudly rather than at the
# first request. TLS is transport encryption only — the bearer-token guard
# above still applies on a non-loopback bind.
tls_cert, tls_key = _resolve_tls_paths()
class _MCPHTTPServer(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
@ -5043,6 +5134,10 @@ def _build_http_server(host: str, port: int):
httpd.enforce_host_pin = _http_is_loopback(host)
httpd.allowed_hosts = _http_allowed_host_values(host, bound_port)
httpd.auth_token = auth_token
httpd.scheme = "http"
if tls_cert:
httpd.socket = _wrap_tls(httpd.socket, tls_cert, tls_key)
httpd.scheme = "https"
return httpd
@ -5076,7 +5171,14 @@ def _serve_http(host: str, port: int) -> None:
_HTTP_ALLOW_INSECURE_NO_TOKEN_ENV,
)
with httpd:
logger.info("MemPalace MCP HTTP server listening on http://%s:%s/mcp", host, bound_port)
logger.info(
"MemPalace MCP HTTP server listening on %s://%s:%s/mcp%s%s",
getattr(httpd, "scheme", "http"),
host,
bound_port,
" (TLS)" if getattr(httpd, "scheme", "http") == "https" else "",
" (read-only)" if _READ_ONLY else "",
)
try:
httpd.serve_forever(poll_interval=0.5)
except KeyboardInterrupt:

View File

@ -197,6 +197,123 @@ def test_bearer_token_enforced_when_configured(monkeypatch):
thread.join(timeout=5)
def test_read_only_hides_and_refuses_mutating_tools(http_server, monkeypatch):
"""Read-only mode (#1877): mutating tools are hidden from tools/list AND
refused at dispatch with -32003, while read tools still work."""
monkeypatch.setattr(mcp, "_READ_ONLY", True)
port, _ = http_server
status, body = _post(port, "/mcp", {"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
assert status == 200
names = {t["name"] for t in json.loads(body)["result"]["tools"]}
assert "mempalace_search" in names # read tool stays
assert "mempalace_add_drawer" not in names # mutating tool hidden
assert names.isdisjoint(mcp._MUTATING_TOOLS)
status, body = _post(
port,
"/mcp",
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": "mempalace_add_drawer", "arguments": {"content": "x"}},
},
)
assert status == 200
assert json.loads(body)["error"]["code"] == -32003
def test_read_only_off_exposes_mutating_tools(http_server):
"""Sanity: without read-only, mutating tools are present (guards the test above)."""
port, _ = http_server
status, body = _post(port, "/mcp", {"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
names = {t["name"] for t in json.loads(body)["result"]["tools"]}
assert "mempalace_add_drawer" in names
def _make_self_signed_cert(tmp_path):
"""Write a throwaway self-signed cert/key via openssl; skip if unavailable."""
import shutil
import subprocess
if shutil.which("openssl") is None:
pytest.skip("openssl not available to generate a test certificate")
cert = tmp_path / "cert.pem"
key = tmp_path / "key.pem"
subprocess.run(
[
"openssl",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-keyout",
str(key),
"-out",
str(cert),
"-days",
"1",
"-nodes",
"-subj",
"/CN=localhost",
],
check=True,
capture_output=True,
)
return cert, key
def test_tls_serves_https(tmp_path, monkeypatch):
"""With --tls-cert/--tls-key (via env), the server speaks TLS: a plain HTTP
client cannot read it, and an HTTPS client trusting the cert can."""
import ssl
cert, key = _make_self_signed_cert(tmp_path)
monkeypatch.setenv("MEMPALACE_MCP_TLS_CERT", str(cert))
monkeypatch.setenv("MEMPALACE_MCP_TLS_KEY", str(key))
httpd = mcp._build_http_server("127.0.0.1", 0)
assert getattr(httpd, "scheme", "http") == "https"
port = httpd.server_address[1]
thread = threading.Thread(
target=httpd.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True
)
thread.start()
try:
# Full verification on: trust the self-signed cert as the CA and dial
# "localhost" (the cert CN, resolves to 127.0.0.1) so hostname checking
# passes without being disabled.
ctx = ssl.create_default_context(cafile=str(cert))
conn = http.client.HTTPSConnection("localhost", port, context=ctx, timeout=5)
try:
conn.request("GET", "/healthz")
resp = conn.getresponse()
assert resp.status == 200
assert resp.read() == b"ok\n"
finally:
conn.close()
# A plaintext HTTP client must NOT be able to talk to the TLS socket.
with pytest.raises(Exception):
plain = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
plain.request("GET", "/healthz")
plain.getresponse()
finally:
httpd.shutdown()
httpd.server_close()
thread.join(timeout=5)
def test_tls_requires_both_cert_and_key(tmp_path, monkeypatch):
"""A cert without a key (or vice versa) is a startup error, not a silent skip."""
cert, _key = _make_self_signed_cert(tmp_path)
monkeypatch.setenv("MEMPALACE_MCP_TLS_CERT", str(cert))
monkeypatch.delenv("MEMPALACE_MCP_TLS_KEY", raising=False)
with pytest.raises(ValueError, match="both"):
mcp._build_http_server("127.0.0.1", 0)
def test_loopback_and_origin_helpers():
assert mcp._http_is_loopback("127.0.0.1")
assert mcp._http_is_loopback("localhost")

160
tests/test_serve.py Normal file
View File

@ -0,0 +1,160 @@
"""Tests for the turnkey `mempalace serve` command (#1877).
These exercise the wrapper's security-relevant behavior — token autogeneration
and 0600 persistence, the secure-by-default non-loopback gate, and that the
bearer token is passed via the environment (never argv, so it can't leak via
``ps``) without binding a real socket. ``cmd_serve`` ends by exec'ing the real
server; we intercept ``os.execve`` to capture the child invocation instead.
"""
import argparse
import os
import stat
import pytest
from mempalace import cli
class _ExecCalled(Exception):
"""Raised by the patched os.execve to stop cmd_serve at the exec boundary."""
@pytest.fixture
def isolated_home(tmp_path, monkeypatch):
"""Point ~ at a temp dir so server token state never touches the real home."""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path)) # Windows
# Don't inherit a token from the ambient environment.
monkeypatch.delenv("MEMPALACE_MCP_HTTP_TOKEN", raising=False)
return tmp_path
@pytest.fixture
def capture_exec(monkeypatch):
"""Capture the child argv/env cmd_serve would launch, instead of running it.
cmd_serve takes the os.execve branch on POSIX and the subprocess.run branch
on Windows. Patch both (rather than forcing os.name, which breaks
Path.home() on Windows) so the test is platform-agnostic.
"""
import subprocess
captured = {}
def _capture(argv, env):
captured["argv"] = argv
captured["env"] = env
raise _ExecCalled()
monkeypatch.setattr(cli.os, "execve", lambda path, argv, env: _capture(argv, env))
monkeypatch.setattr(subprocess, "run", lambda argv, env=None, **kw: _capture(argv, env))
return captured
def _serve_args(tmp_path, **over):
base = dict(
host="127.0.0.1",
port=8765,
backend=None,
global_backend=None,
palace=str(tmp_path / "palace"),
token=None,
tls_cert=None,
tls_key=None,
read_only=False,
allow_insecure=False,
)
base.update(over)
return argparse.Namespace(**base)
def test_token_helper_creates_0600_and_reuses(isolated_home):
palace = str(isolated_home / "palace")
token1, created1 = cli._load_or_create_server_token(palace)
assert created1 is True
assert token1
path = cli._server_token_path(palace)
assert path.exists()
if os.name == "posix":
# POSIX permission bits aren't meaningful on Windows (files report 0o666).
mode = stat.S_IMODE(path.stat().st_mode)
assert mode == 0o600, oct(mode)
dir_mode = stat.S_IMODE(path.parent.stat().st_mode)
assert dir_mode == 0o700, oct(dir_mode)
token2, created2 = cli._load_or_create_server_token(palace)
assert created2 is False
assert token2 == token1 # stable across restarts
def test_loopback_serve_needs_no_token(isolated_home, capture_exec):
with pytest.raises(_ExecCalled):
cli.cmd_serve(_serve_args(isolated_home, host="127.0.0.1"))
env = capture_exec["env"]
assert "MEMPALACE_MCP_HTTP_TOKEN" not in env
assert "MEMPALACE_MCP_HTTP_ALLOW_INSECURE_NO_TOKEN" not in env
# No token persisted for a loopback bind.
assert not cli._server_token_path(str(isolated_home / "palace")).exists()
def test_non_loopback_autogenerates_token_in_env_not_argv(isolated_home, capture_exec):
with pytest.raises(_ExecCalled):
cli.cmd_serve(_serve_args(isolated_home, host="0.0.0.0"))
env = capture_exec["env"]
argv = capture_exec["argv"]
token = env.get("MEMPALACE_MCP_HTTP_TOKEN")
assert token, "a token must be generated for a network-exposed bind"
# Security: the token rides in the env, never on the command line.
assert all(token not in part for part in argv)
assert "--token" not in argv
# And it was persisted for reuse on the next start (0600 on POSIX).
path = cli._server_token_path(str(isolated_home / "palace"))
assert path.exists()
if os.name == "posix":
assert stat.S_IMODE(path.stat().st_mode) == 0o600
def test_allow_insecure_skips_token_and_sets_escape_hatch(isolated_home, capture_exec):
with pytest.raises(_ExecCalled):
cli.cmd_serve(_serve_args(isolated_home, host="0.0.0.0", allow_insecure=True))
env = capture_exec["env"]
assert env.get("MEMPALACE_MCP_HTTP_ALLOW_INSECURE_NO_TOKEN") == "1"
assert "MEMPALACE_MCP_HTTP_TOKEN" not in env
assert not cli._server_token_path(str(isolated_home / "palace")).exists()
def test_read_only_flag_forwarded_to_child(isolated_home, capture_exec):
with pytest.raises(_ExecCalled):
cli.cmd_serve(_serve_args(isolated_home, read_only=True))
assert "--read-only" in capture_exec["argv"]
def test_explicit_token_is_used_and_not_in_argv(isolated_home, capture_exec):
with pytest.raises(_ExecCalled):
cli.cmd_serve(_serve_args(isolated_home, host="0.0.0.0", token="my-secret-token"))
env = capture_exec["env"]
argv = capture_exec["argv"]
assert env["MEMPALACE_MCP_HTTP_TOKEN"] == "my-secret-token"
assert all("my-secret-token" not in part for part in argv)
# An explicitly-provided token is not persisted to the server token file.
assert not cli._server_token_path(str(isolated_home / "palace")).exists()
def test_tls_paths_forwarded_and_validated(isolated_home, capture_exec, tmp_path):
cert = tmp_path / "cert.pem"
key = tmp_path / "key.pem"
cert.write_text("x")
key.write_text("x")
with pytest.raises(_ExecCalled):
cli.cmd_serve(_serve_args(isolated_home, tls_cert=str(cert), tls_key=str(key)))
argv = capture_exec["argv"]
assert "--tls-cert" in argv and "--tls-key" in argv
def test_tls_requires_both_cert_and_key(isolated_home, capture_exec, tmp_path):
cert = tmp_path / "cert.pem"
cert.write_text("x")
with pytest.raises(SystemExit):
cli.cmd_serve(_serve_args(isolated_home, tls_cert=str(cert), tls_key=None))

View File

@ -89,35 +89,46 @@ and needs no extra.
## 3. Serve MCP over HTTP
The MCP server speaks JSON-RPC over `POST /mcp` and exposes an unauthenticated
`GET /healthz` liveness probe for orchestrators. Binding to a **non-loopback**
host requires a bearer token — MemPalace refuses to start otherwise.
One command — `mempalace serve` — runs the server with secure defaults. On a
network-exposed (`0.0.0.0`) bind it **auto-generates a strong bearer token**
(stored `0600` under `~/.mempalace/server/`, printed once), prints a
ready-to-paste client config, and runs in the foreground so Docker/systemd own
the lifecycle.
```bash
export MEMPALACE_MCP_HTTP_TOKEN="$(openssl rand -hex 32)"
mempalace-mcp --transport http --host 0.0.0.0 --port 8765 --backend qdrant
mempalace serve --host 0.0.0.0 --port 8765 --backend qdrant
```
| Flag / variable | Default | Purpose |
Output includes the token and the exact client command. Useful flags:
| Flag | Default | Purpose |
|---|---|---|
| `--transport http` | `stdio` | Serve over HTTP instead of stdio |
| `--host` | `127.0.0.1` | Bind address (`0.0.0.0` to accept remote clients) |
| `--port` | `8765` | Listen port |
| `MEMPALACE_MCP_HTTP_TOKEN` | _(none)_ | **Required** for non-loopback binds; clients send `Authorization: Bearer <token>` |
| `--backend` | config/env | Storage backend (e.g. `qdrant`) |
| `--tls-cert` / `--tls-key` | _(none)_ | PEM cert + key to terminate **TLS natively** (server speaks `https`) |
| `--read-only` | off | Expose recall only — the mutating tools are hidden and refused |
| `--token` | auto | Use a specific bearer token instead of the generated one |
| `--allow-insecure` | off | Permit a non-loopback bind with no token (only behind a trusted proxy) |
The server protects against DNS-rebinding with a `Host` allowlist and an
`Origin` loopback check, and serializes concurrent writes — so multiple
teammates can write to the shared palace at once over HTTP.
The token always travels via the environment, never the command line, so it
can't leak through `ps`. Binding to a non-loopback host with no token and no
`--allow-insecure` refuses to start. The server also guards against
DNS-rebinding with a `Host` allowlist and an `Origin` loopback check, and
serializes concurrent writes — so multiple teammates can write to the shared
palace at once over HTTP.
::: danger Put TLS in front of it
The HTTP server is plaintext. For anything beyond a trusted private network,
run it behind a reverse proxy (nginx/Caddy/Traefik) terminating TLS, and keep
the bearer token secret. Only set
`MEMPALACE_MCP_HTTP_ALLOW_INSECURE_NO_TOKEN=1` when a trusted fronting layer
already enforces access control — never on a directly-exposed port.
::: tip TLS
Pass `--tls-cert`/`--tls-key` to terminate TLS in the server itself
(`https://…`). Otherwise the server is plaintext and you should front it with a
TLS-terminating reverse proxy (nginx/Caddy/Traefik) — never expose plaintext
`/mcp` beyond a trusted private network.
:::
The underlying server is `mempalace-mcp --transport http` (the same flags exist
there if you'd rather wire the token/TLS yourself); `mempalace serve` is the
turnkey wrapper over it.
## 4. Connect a client
Point each teammate's MCP client at the server's `/mcp` endpoint with the
@ -151,6 +162,29 @@ whole team.
- **Backups** are now your storage backend's responsibility (Qdrant snapshots
/ Postgres backups) rather than a single laptop's palace directory.
## One-command deployments
The repo ships ready-to-edit deployment files under
[`deploy/`](https://github.com/MemPalace/mempalace/tree/main/deploy):
**Docker Compose (server + Qdrant):**
```bash
cp deploy/server.env.example deploy/.env # set MEMPALACE_MCP_HTTP_TOKEN
docker compose -f deploy/docker-compose.server.yml --env-file deploy/.env up -d
```
This brings up a Qdrant container and a MemPalace server running
`serve --host 0.0.0.0 --backend qdrant`, with a `/healthz` healthcheck and
persistent volumes. Embeddings stay local to the MemPalace container.
**systemd:**
`deploy/mempalace-server.service` is a hardened unit template
(`NoNewPrivileges`, `ProtectSystem=strict`, dedicated user) that runs
`mempalace serve` with its config from `/etc/mempalace/server.env`. Install
steps are in the file's header comment.
## See also
- [MCP Integration](/guide/mcp-integration) — the tools clients get once connected