diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index 2785191..9c6a476 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -55,6 +55,7 @@ import time # noqa: E402 from datetime import date, datetime # noqa: E402 from pathlib import Path # noqa: E402 from typing import Optional # noqa: E402 +from urllib.parse import urlparse # noqa: E402 from .config import ( # noqa: E402 MempalaceConfig, @@ -5311,6 +5312,8 @@ def _json_rpc_parse_error(req_id=None): # can reference them as free names without a NameError. _HTTP_REQUEST_LOCK = threading.Lock() _HTTP_MAX_REQUEST_BYTES = 16 * 1024 * 1024 +_HTTP_ACTIVE_CLIENT_WINDOW_S = 120.0 +_HTTP_RECENT_CLIENT_LIMIT = 50 # Host literals that always denote this machine. Used both to decide whether a # bind is loopback (skip the network-exposure warning) and to pin the Host # header against DNS rebinding when serving on loopback. @@ -5407,6 +5410,143 @@ def _http_origin_allowed(origin: str) -> bool: return host in ("127.0.0.1", "localhost", "::1") +def _http_client_identity(handler) -> tuple[str, dict]: + headers = handler.headers + peer = handler.client_address[0] if handler.client_address else "" + forwarded_for = (headers.get("X-Forwarded-For") or "").split(",", 1)[0].strip() + real_ip = (headers.get("X-Real-IP") or "").strip() + tailnet_user = (headers.get("Tailscale-User-Login") or "").strip() + user_agent = (headers.get("User-Agent") or "").strip() + host_hdr = (headers.get("Host") or "").strip() + peer_hint = forwarded_for or real_ip or peer + basis = "|".join([peer_hint, tailnet_user, user_agent, host_hdr]) + client_id = hashlib.sha256(basis.encode("utf-8", "replace")).hexdigest()[:16] + return client_id, { + "client_id": client_id, + "peer": peer, + "peer_hint": peer_hint, + "host": host_hdr, + "user_agent": user_agent[:160], + "tailscale_user": tailnet_user[:160], + } + + +def _http_record_request(httpd, handler, status: int) -> None: + now = time.time() + now_iso = datetime.now().isoformat() + client_id, identity = _http_client_identity(handler) + with httpd.stats_lock: + httpd.request_count += 1 + httpd.status_counts[str(status)] = httpd.status_counts.get(str(status), 0) + 1 + entry = dict(httpd.recent_clients.get(client_id, identity)) + entry.update(identity) + entry["last_seen"] = now_iso + entry["last_seen_monotonic"] = now + entry["request_count"] = int(entry.get("request_count", 0)) + 1 + entry["last_method"] = handler.command + entry["last_path"] = urlparse(handler.path).path + entry["last_status"] = status + entry["authenticated"] = bool( + httpd.auth_token + and hmac.compare_digest( + handler.headers.get("Authorization", ""), f"Bearer {httpd.auth_token}" + ) + ) + httpd.recent_clients[client_id] = entry + overflow = len(httpd.recent_clients) - _HTTP_RECENT_CLIENT_LIMIT + if overflow > 0: + oldest = sorted( + httpd.recent_clients.items(), + key=lambda item: item[1].get("last_seen_monotonic", 0.0), + ) + for stale_id, _entry in oldest[:overflow]: + httpd.recent_clients.pop(stale_id, None) + + +def _http_status_payload(httpd) -> dict: + now = time.time() + with httpd.stats_lock: + recent = sorted( + (dict(entry) for entry in httpd.recent_clients.values()), + key=lambda entry: entry.get("last_seen_monotonic", 0.0), + reverse=True, + ) + request_count = httpd.request_count + status_counts = dict(httpd.status_counts) + + active = [] + for entry in recent: + last_seen_monotonic = entry.pop("last_seen_monotonic", 0.0) + if now - last_seen_monotonic <= _HTTP_ACTIVE_CLIENT_WINDOW_S: + active.append(dict(entry)) + + writer = { + "read_only": _READ_ONLY, + "peer_writer_read_only": _MCP_WRITER_READ_ONLY, + "peer_writer_lock_failed": _MCP_WRITER_LOCK_FAILED, + } + if _MCP_WRITER_LOCK_ERROR: + writer["peer_writer_lock_error"] = _MCP_WRITER_LOCK_ERROR + + integrity = _sqlite_integrity_payload() + palace_path = ( + os.path.abspath(os.path.expanduser(_config.palace_path)) if _config.palace_path else "" + ) + return { + "ok": bool(integrity.get("ok")), + "server": { + "name": "mempalace", + "version": __version__, + "transport": "http", + "scheme": getattr(httpd, "scheme", "http"), + "bind_host": httpd.bind_host, + "port": httpd.server_address[1], + "started_at": httpd.started_at, + "uptime_seconds": round(time.monotonic() - httpd.started_monotonic, 3), + }, + "palace": { + "path_hash": hashlib.sha256(palace_path.encode("utf-8")).hexdigest()[:16] + if _config.palace_path + else "", + "backend": _config.backend, + "sqlite_integrity": integrity, + }, + "writer": writer, + "requests": { + "total": request_count, + "by_status": status_counts, + }, + "clients": { + "active_window_seconds": _HTTP_ACTIVE_CLIENT_WINDOW_S, + "active": active, + "recent": recent, + }, + } + + +def _http_request_rejected(handler, require_auth: bool) -> bool: + """Enforce HTTP Host/Origin/auth policy before dispatching a request.""" + srv = handler.server + if srv.enforce_host_pin: + host_hdr = (handler.headers.get("Host") or "").strip().lower() + if host_hdr not in srv.allowed_hosts: + logger.warning("HTTP request rejected: Host %r not allowed", host_hdr) + handler.send_error(403, "Forbidden") + return True + origin = handler.headers.get("Origin") + if origin and not _http_origin_allowed(origin): + logger.warning("HTTP request rejected: cross-origin %r", origin) + handler.send_error(403, "Forbidden") + return True + if require_auth and srv.auth_token: + provided = handler.headers.get("Authorization", "") + if not hmac.compare_digest(provided, f"Bearer {srv.auth_token}"): + logger.warning("HTTP request rejected: missing/invalid bearer token") + handler.send_error(401, "Unauthorized") + return True + return False + + def _build_http_server(host: str, port: int): """Construct (but do not start) the MCP HTTP server. @@ -5417,7 +5557,6 @@ def _build_http_server(host: str, port: int): allowlist, Origin check, optional bearer token) is attached as attributes. """ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - from urllib.parse import urlparse auth_token = os.environ.get("MEMPALACE_MCP_HTTP_TOKEN", "").strip() if ( @@ -5469,7 +5608,18 @@ def _build_http_server(host: str, port: int): def log_message(self, fmt, *args): logger.info("HTTP %s - " + fmt, self.client_address[0], *args) + def send_error(self, code, message=None, explain=None): + self._record_request(code) + return super().send_error(code, message, explain) + + def _record_request(self, status: int) -> None: + _http_record_request(self.server, self, status) + + def _status_payload(self) -> dict: + return _http_status_payload(self.server) + def _send_bytes(self, status: int, body: bytes, content_type: str) -> None: + self._record_request(status) self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) @@ -5483,42 +5633,24 @@ def _build_http_server(host: str, port: int): self._send_bytes(status, body, "application/json; charset=utf-8") def _request_rejected(self, require_auth: bool) -> bool: - """Enforce the transport's access policy before any dispatch. - - The palace is the most sensitive data MemPalace holds and ``/mcp`` - is unauthenticated by default, so this guards the two ways a local - HTTP server leaks to the network: DNS rebinding (Host/Origin) and, - when the operator opts in, a missing/incorrect bearer token. - """ - srv = self.server - if srv.enforce_host_pin: - host_hdr = (self.headers.get("Host") or "").strip().lower() - if host_hdr not in srv.allowed_hosts: - logger.warning("HTTP request rejected: Host %r not allowed", host_hdr) - self.send_error(403, "Forbidden") - return True - origin = self.headers.get("Origin") - if origin and not _http_origin_allowed(origin): - logger.warning("HTTP request rejected: cross-origin %r", origin) - self.send_error(403, "Forbidden") - return True - if require_auth and srv.auth_token: - provided = self.headers.get("Authorization", "") - if not hmac.compare_digest(provided, f"Bearer {srv.auth_token}"): - logger.warning("HTTP request rejected: missing/invalid bearer token") - self.send_error(401, "Unauthorized") - return True - return False + return _http_request_rejected(self, require_auth) def do_GET(self): # Liveness probe is policy-gated for Host/Origin but never requires # the token, so an orchestrator's health check works without creds. - if self._request_rejected(require_auth=False): - return path = urlparse(self.path).path if path == "/healthz": + if self._request_rejected(require_auth=False): + return self._send_bytes(200, b"ok\n", "text/plain; charset=utf-8") return + if path == "/statusz": + if self._request_rejected(require_auth=True): + return + self._send_json(200, self._status_payload()) + return + if self._request_rejected(require_auth=False): + return self.send_error(404, "Not Found") @@ -5562,6 +5694,7 @@ def _build_http_server(host: str, port: int): if response is None: # JSON-RPC notifications intentionally have no response body. + self._record_request(202) self.send_response(202) self.send_header("Content-Length", "0") self.send_header("Connection", "close") @@ -5581,6 +5714,13 @@ def _build_http_server(host: str, port: int): httpd.allowed_hosts = _http_allowed_host_values(host, bound_port) httpd.auth_token = auth_token httpd.scheme = "http" + httpd.bind_host = host + httpd.started_at = datetime.now().isoformat() + httpd.started_monotonic = time.monotonic() + httpd.stats_lock = threading.Lock() + httpd.request_count = 0 + httpd.status_counts = {} + httpd.recent_clients = {} if tls_cert: httpd.socket = _wrap_tls(httpd.socket, tls_cert, tls_key) httpd.scheme = "https" diff --git a/tests/test_mcp_http_transport.py b/tests/test_mcp_http_transport.py index 1db91e4..2e98578 100644 --- a/tests/test_mcp_http_transport.py +++ b/tests/test_mcp_http_transport.py @@ -106,6 +106,33 @@ def test_healthz_ok(http_server): assert body == b"ok\n" +def test_statusz_reports_machine_readable_server_and_client_state(http_server, monkeypatch): + monkeypatch.setattr(mcp, "_sqlite_integrity_payload", lambda: {"ok": True, "errors": []}) + port, _ = http_server + + assert _get(port, "/healthz", headers={"User-Agent": "codex-test"})[0] == 200 + status, body = _get(port, "/statusz", headers={"User-Agent": "codex-test"}) + + assert status == 200 + payload = json.loads(body) + assert payload["ok"] is True + assert payload["server"]["name"] == "mempalace" + assert payload["server"]["transport"] == "http" + assert payload["server"]["port"] == port + assert payload["requests"]["total"] >= 1 + assert payload["requests"]["by_status"]["200"] >= 1 + assert payload["clients"]["active_window_seconds"] == mcp._HTTP_ACTIVE_CLIENT_WINDOW_S + assert payload["clients"]["recent"] + first = payload["clients"]["recent"][0] + assert first["peer"] == "127.0.0.1" + assert first["peer_hint"] == "127.0.0.1" + assert first["user_agent"] == "codex-test" + assert first["last_path"] == "/healthz" + assert "Authorization" not in json.dumps(payload) + if mcp._config.palace_path: + assert mcp._config.palace_path not in json.dumps(payload) + + def test_unknown_path_404(http_server): port, _ = http_server assert _post(port, "/nope", {"jsonrpc": "2.0", "id": 1, "method": "ping"})[0] == 404 @@ -179,6 +206,7 @@ def test_allows_loopback_origin(http_server): def test_bearer_token_enforced_when_configured(monkeypatch): """With MEMPALACE_MCP_HTTP_TOKEN set, /mcp requires a matching bearer token.""" monkeypatch.setenv("MEMPALACE_MCP_HTTP_TOKEN", "s3cret") + monkeypatch.setattr(mcp, "_sqlite_integrity_payload", lambda: {"ok": True, "errors": []}) httpd = mcp._build_http_server("127.0.0.1", 0) port = httpd.server_address[1] thread = threading.Thread( @@ -195,6 +223,11 @@ def test_bearer_token_enforced_when_configured(monkeypatch): assert _post(port, "/mcp", ping, headers={"Authorization": "Bearer s3cret"})[0] == 200 # /healthz never requires the token (orchestrator liveness probes). assert _get(port, "/healthz")[0] == 200 + # /statusz exposes server/client metadata, so it follows the auth policy. + assert _get(port, "/statusz")[0] == 401 + status, body = _get(port, "/statusz", headers={"Authorization": "Bearer s3cret"}) + assert status == 200 + assert "recent" in json.loads(body)["clients"] finally: httpd.shutdown() httpd.server_close() diff --git a/website/guide/remote-server.md b/website/guide/remote-server.md index e0c39cb..2987e21 100644 --- a/website/guide/remote-server.md +++ b/website/guide/remote-server.md @@ -169,6 +169,8 @@ Other MCP clients use the same two ingredients — the `…/mcp` URL and an ```bash curl https://memory.example.com/healthz # -> ok +curl https://memory.example.com/statusz \ + -H "Authorization: Bearer $MEMPALACE_MCP_HTTP_TOKEN" ``` Once connected, all of MemPalace's [MCP tools](/guide/mcp-integration) operate @@ -189,7 +191,11 @@ whole team. process safely handles concurrent reads and writes. Don't point two server processes at the same backend collection. - **Health checks**: `GET /healthz` returns `200 ok` without a token, so it - works as a load-balancer/Kubernetes liveness probe. + works as a load-balancer/Kubernetes liveness probe. For machine-readable + server state, `GET /statusz` returns JSON with version, uptime, request + counters, SQLite integrity, writer mode, and recent observed MCP clients. + `/statusz` follows the bearer-token policy because it exposes operational + metadata; it is not a public liveness probe. - **Fronting proxies (Tailscale, nginx)**: the recommended personal-fleet setup is a loopback bind behind a tailnet-only proxy — nothing touches the physical LAN and the tailnet provides encryption plus device identity: