Add persisted target registry for Cursor golden profiles.

Targets save to PFSENSE_TARGETS_PATH so golden-pfsense and other firewalls reload across MCP sessions.
This commit is contained in:
2026-06-12 22:54:07 +03:00
parent 9fa6a0735f
commit 3ccd12d923
5 changed files with 134 additions and 12 deletions

View File

@@ -14,3 +14,4 @@ PFSENSE_DRY_RUN=false
PFSENSE_REQUIRE_DESCRIPTION_PREFIX=mcp: PFSENSE_REQUIRE_DESCRIPTION_PREFIX=mcp:
PFSENSE_BLOCK_WAN_INBOUND_ANY=true PFSENSE_BLOCK_WAN_INBOUND_ANY=true
PFSENSE_AUDIT_LOG_PATH=/root/.hermes/logs/pfsense-mcp-audit.jsonl PFSENSE_AUDIT_LOG_PATH=/root/.hermes/logs/pfsense-mcp-audit.jsonl
PFSENSE_TARGETS_PATH=~/.local/share/pfsense-mcp/targets.json

View File

@@ -23,6 +23,7 @@ class Settings(BaseSettings):
require_description_prefix: str = "mcp:" require_description_prefix: str = "mcp:"
block_wan_inbound_any: bool = True block_wan_inbound_any: bool = True
audit_log_path: str = "/root/.hermes/logs/pfsense-mcp-audit.jsonl" audit_log_path: str = "/root/.hermes/logs/pfsense-mcp-audit.jsonl"
targets_path: str = "~/.local/share/pfsense-mcp/targets.json"
settings = Settings() settings = Settings()

View File

@@ -14,9 +14,10 @@ mcp = FastMCP(
"pfsense", "pfsense",
instructions=( instructions=(
"pfSense firewall MCP for multiple firewalls (REST API v2 package required). " "pfSense firewall MCP for multiple firewalls (REST API v2 package required). "
"Always call pfsense_connect(url, api_key, name=...) before other tools, or pass " "Saved targets load from PFSENSE_TARGETS_PATH on startup. Use pfsense_list_targets, "
"target/url/api_key on each call. Use pfsense_list_targets and pfsense_use_target to " "pfsense_use_target('name'), or pfsense_connect(url, api_key, name=...) to register more. "
"switch between firewalls. Write tools require PFSENSE_ALLOW_WRITES=true. " "Pass target='name' on tools, or rely on the active saved target. "
"Write tools require PFSENSE_ALLOW_WRITES=true. "
f"New firewall rules must use descr prefix '{settings.require_description_prefix}'." f"New firewall rules must use descr prefix '{settings.require_description_prefix}'."
), ),
) )

View File

@@ -50,6 +50,7 @@ class TargetRegistry:
def __init__(self) -> None: def __init__(self) -> None:
self._targets: dict[str, TargetConfig] = {} self._targets: dict[str, TargetConfig] = {}
self._active: str | None = None self._active: str | None = None
self._loading: bool = False
@staticmethod @staticmethod
def _normalize_name(name: str) -> str: def _normalize_name(name: str) -> str:
@@ -77,20 +78,24 @@ class TargetRegistry:
password: str = "", password: str = "",
verify_ssl: bool = False, verify_ssl: bool = False,
description: str = "", description: str = "",
registered_at: str = "",
set_active: bool = True, set_active: bool = True,
) -> TargetConfig: ) -> TargetConfig:
if not url.strip(): if not url.strip():
raise GuardError("url is required (e.g. 'https://10.77.50.1' or '10.77.50.1').") raise GuardError("url is required (e.g. 'https://10.77.50.1' or '10.77.50.1').")
target_name = self._normalize_name(name) if name.strip() else self._derive_name(url) target_name = self._normalize_name(name) if name.strip() else self._derive_name(url)
cfg = TargetConfig( cfg_kwargs: dict[str, Any] = {
name=target_name, "name": target_name,
url=url, "url": url,
api_key=api_key, "api_key": api_key,
username=username, "username": username,
password=password, "password": password,
verify_ssl=verify_ssl, "verify_ssl": verify_ssl,
description=description, "description": description,
) }
if registered_at.strip():
cfg_kwargs["registered_at"] = registered_at.strip()
cfg = TargetConfig(**cfg_kwargs)
if not cfg.has_credentials(): if not cfg.has_credentials():
raise GuardError( raise GuardError(
"api_key is required (pfSense REST API v2 key from System → REST API → Keys)." "api_key is required (pfSense REST API v2 key from System → REST API → Keys)."
@@ -98,6 +103,7 @@ class TargetRegistry:
self._targets[target_name] = cfg self._targets[target_name] = cfg
if set_active: if set_active:
self._active = target_name self._active = target_name
self._persist()
return cfg return cfg
def unregister(self, name: str) -> None: def unregister(self, name: str) -> None:
@@ -107,14 +113,23 @@ class TargetRegistry:
del self._targets[key] del self._targets[key]
if self._active == key: if self._active == key:
self._active = next(iter(self._targets), None) self._active = next(iter(self._targets), None)
self._persist()
def set_active(self, name: str) -> TargetConfig: def set_active(self, name: str) -> TargetConfig:
key = self._normalize_name(name) key = self._normalize_name(name)
if key not in self._targets: if key not in self._targets:
raise GuardError(f"Unknown target '{name}'. Call pfsense_connect first.") raise GuardError(f"Unknown target '{name}'. Call pfsense_connect first.")
self._active = key self._active = key
self._persist()
return self._targets[key] return self._targets[key]
def _persist(self) -> None:
if self._loading:
return
from pfsense_mcp.targets_store import save_registry
save_registry(self)
def get(self, name: str) -> TargetConfig: def get(self, name: str) -> TargetConfig:
key = self._normalize_name(name) key = self._normalize_name(name)
if key not in self._targets: if key not in self._targets:
@@ -188,6 +203,15 @@ class TargetRegistry:
registry = TargetRegistry() registry = TargetRegistry()
def _load_persisted_targets() -> None:
from pfsense_mcp.targets_store import load_registry
load_registry(registry)
_load_persisted_targets()
def resolve_client( def resolve_client(
target: str = "", target: str = "",
url: str = "", url: str = "",

View File

@@ -0,0 +1,95 @@
"""Persist registered pfSense targets to disk for reuse across MCP sessions."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pfsense_mcp.targets import TargetConfig, TargetRegistry
def targets_path() -> Path:
from pfsense_mcp.config import settings
return Path(os.path.expanduser(settings.targets_path))
def load_registry(registry: TargetRegistry) -> None:
path = targets_path()
if not path.exists():
return
try:
data = json.loads(path.read_text())
except (OSError, json.JSONDecodeError):
return
registry._loading = True
loaded_active: str | None = None
for entry in data.get("targets", []):
if not isinstance(entry, dict):
continue
name = str(entry.get("name", "")).strip()
url = str(entry.get("url", "")).strip()
if not name or not url:
continue
try:
registry.register(
name=name,
url=url,
api_key=str(entry.get("api_key", "")),
username=str(entry.get("username", "")),
password=str(entry.get("password", "")),
verify_ssl=bool(entry.get("verify_ssl", False)),
description=str(entry.get("description", "")),
registered_at=str(entry.get("registered_at", "")),
set_active=False,
)
except Exception:
continue
active = str(data.get("active", "")).strip()
if active:
try:
registry.set_active(active)
loaded_active = active
except Exception:
pass
if not loaded_active and registry.list():
first = registry.list()[0]["name"]
registry.set_active(first)
registry._loading = False
def save_registry(registry: TargetRegistry) -> None:
path = targets_path()
path.parent.mkdir(parents=True, exist_ok=True)
targets: list[dict[str, Any]] = []
for cfg in registry._targets.values():
targets.append(
{
"name": cfg.name,
"url": cfg.normalized_url(),
"api_key": cfg.api_key,
"username": cfg.username,
"password": cfg.password,
"verify_ssl": cfg.verify_ssl,
"description": cfg.description,
"registered_at": cfg.registered_at,
}
)
payload = {
"active": registry._active,
"targets": sorted(targets, key=lambda row: row["name"]),
}
path.write_text(json.dumps(payload, indent=2) + "\n")
try:
path.chmod(0o600)
except OSError:
pass