* 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).
161 lines
5.9 KiB
Python
161 lines
5.9 KiB
Python
"""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))
|