Files
labyricorn-site/labyricorn_mcp/domain.py
T
Labyricorn fe6025fa63
Deploy production / deploy (push) Successful in 21s
Add local repository MCP server
2026-08-23 10:48:33 -07:00

562 lines
26 KiB
Python

"""Domain adapters over the site's existing editors and importer."""
from __future__ import annotations
import configparser
from dataclasses import asdict
from datetime import date
import hashlib
import json
import os
from pathlib import Path
import re
import sys
import tempfile
from typing import Any
from .errors import LabyricornMcpError
from .safety import RepositoryGuard
DESIGN_ROOTS = ("templates", "assets/static")
DESIGN_SUFFIXES = {".html", ".css", ".js", ".json", ".svg", ".txt", ".xml"}
CONTENT_MODELS = {"article", "blog", "project", "devlog"}
def _hash(payload: bytes) -> str:
return hashlib.sha256(payload).hexdigest()
def _load_site_modules(root: Path) -> dict[str, Any]:
scripts = str(root / "scripts")
if scripts not in sys.path:
sys.path.insert(0, scripts)
try:
import article_editor
import blog_editor
import project_editor
import tag_editor
except (ImportError, OSError) as exc:
raise LabyricornMcpError(
"INVALID_LABYRICORN_REPOSITORY", "The repository's management modules could not be loaded."
) from exc
return {
"article": article_editor,
"blog": blog_editor,
"project": project_editor,
"tag": tag_editor,
}
class LabyricornDomain:
def __init__(self, guard: RepositoryGuard):
self.guard = guard
self.modules = _load_site_modules(guard.root)
def _article_repository(self) -> Any:
repository = self.modules["article"].ArticleRepository(self.guard.root)
self._require_contained(repository.article_root, repository.tags_root)
return repository
def _blog_repository(self) -> Any:
repository = self.modules["blog"].BlogRepository(self.guard.root)
self._require_contained(repository.blog_root, repository.tags_root)
return repository
def _tag_repository(self) -> Any:
repository = self.modules["tag"].TagRepository(self.guard.root)
self._require_contained(repository.tags_root)
return repository
def _project_registry(self) -> Any:
return self.modules["project"].ProjectSourceRegistry(self.guard.root)
def _require_contained(self, *paths: Path) -> None:
if any(not path.resolve().is_relative_to(self.guard.root) for path in paths):
raise LabyricornMcpError(
"PATH_OUTSIDE_REPOSITORY", "A required content path resolves outside the repository."
)
def _translate(self, exc: Exception, *, not_found: bool = False) -> LabyricornMcpError:
message = str(exc).replace(str(self.guard.root), ".")
folded = message.casefold()
if "changed on disk" in folded or "reload" in folded:
code = "FILE_CHANGED"
elif not_found or "does not exist" in folded:
code = "CONTENT_NOT_FOUND"
elif "already exists" in folded:
code = "WORKTREE_CONFLICT"
elif "unsafe" in folded or "outside" in folded or "linked" in folded:
code = "PATH_OUTSIDE_REPOSITORY"
else:
code = "VALIDATION_FAILED"
return LabyricornMcpError(code, message)
def site_structure(self) -> dict[str, Any]:
return {
"content": {
"articles": {"path": "content/articles", "model": "entry", "kicker": "Article", "ownership": "site"},
"blog": {"path": "content/blog", "model": "entry", "kicker": "Blog", "ownership": "site"},
"tags": {"path": "content/tags", "model": "tag", "ownership": "site"},
"projects": {
"path": "content/projects",
"models": ["project", "devlog", "devlog-entry"],
"ownership": "validated remote sources",
"source_registry": "configs/project-sources.ini",
},
},
"design": {
"inspectable_roots": list(DESIGN_ROOTS),
"hash_guarded_text_mutation_roots": list(DESIGN_ROOTS),
"binary_assets": "metadata and hash inspection only",
},
"models_path": "models",
"build_entry_point": "scripts/build_with_projects.py",
"transport": "stdio",
}
def describe_model(self, model: str) -> dict[str, Any]:
if not isinstance(model, str) or not re.fullmatch(r"[a-z0-9-]+", model):
raise LabyricornMcpError("INVALID_CONTENT_MODEL", "Model names must use lowercase letters, numbers, and hyphens.")
path = self.guard.resolve(f"models/{model}.ini", allowed_roots=("models",), must_exist=True)
parser = configparser.ConfigParser(interpolation=None)
try:
parser.read(path, encoding="utf-8")
except configparser.Error as exc:
raise LabyricornMcpError("VALIDATION_FAILED", "The model definition is malformed.") from exc
fields: list[dict[str, Any]] = []
for section in parser.sections():
if not section.startswith("fields."):
continue
values = dict(parser.items(section))
fields.append({"name": section.removeprefix("fields."), **values})
return {
"model": model,
"name": parser.get("model", "name", fallback=model),
"label": parser.get("model", "label", fallback=""),
"hidden": parser.getboolean("model", "hidden", fallback=False),
"protected": parser.getboolean("model", "protected", fallback=False),
"children": dict(parser.items("children")) if parser.has_section("children") else None,
"fields": fields,
"source": self.guard.relative(path),
"sha256": _hash(path.read_bytes()),
}
def _entry_result(self, model: str, entry: Any, repository: Any, *, include_body: bool) -> dict[str, Any]:
values = repository.values_for(entry.document)
if not include_body:
values.pop("body", None)
return {
"model": model,
"slug": entry.slug,
"path": self.guard.relative(entry.path),
"fields": values,
"sha256": _hash(entry.original_bytes),
}
def list_content(self, filters: dict[str, Any]) -> dict[str, Any]:
requested = filters.get("model")
models = [requested] if requested else ["article", "blog", "project", "devlog"]
if any(model not in CONTENT_MODELS for model in models):
raise LabyricornMcpError("INVALID_CONTENT_MODEL", "Supported content models are article, blog, project, and devlog.")
items: list[dict[str, Any]] = []
if "article" in models:
repository = self._article_repository()
items.extend(self._entry_result("article", item, repository, include_body=False) for item in repository.list_entries())
if "blog" in models:
repository = self._blog_repository()
items.extend(self._entry_result("blog", item, repository, include_body=False) for item in repository.list_entries())
if "project" in models or "devlog" in models:
for project in self.list_projects()["projects"]:
if "project" in models:
items.append({"model": "project", **project})
if "devlog" in models:
try:
items.extend(self.list_project_devlogs(project["project_id"])["entries"])
except LabyricornMcpError as exc:
if exc.code not in {"CONTENT_NOT_FOUND", "RUNTIME_MISSING"}:
raise
tag = str(filters.get("tag") or "").strip()
project_id = str(filters.get("project") or "").strip()
slug = str(filters.get("slug") or "").strip()
date_value = str(filters.get("date") or "").strip()
search = str(filters.get("search") or "").strip().casefold()
def matches(item: dict[str, Any]) -> bool:
fields = item.get("fields", {})
tags = fields.get("tags", [])
if isinstance(tags, str):
tags = [part.strip() for part in re.split(r"[,\n]", tags) if part.strip()]
if tag and tag not in tags and tag not in fields.get("topic_tag_slugs", []):
return False
if project_id and item.get("project_id") != project_id:
return False
if slug and item.get("slug") != slug:
return False
if date_value and fields.get("date") != date_value:
return False
if search and search not in json.dumps(item, ensure_ascii=False).casefold():
return False
return True
filtered = [item for item in items if matches(item)]
return {"count": len(filtered), "items": filtered}
def get_content(self, model: str, slug: str, project: str | None = None) -> dict[str, Any]:
try:
if model == "article":
repository = self._article_repository()
return self._entry_result(model, repository.load_entry(slug), repository, include_body=True)
if model == "blog":
repository = self._blog_repository()
return self._entry_result(model, repository.load_entry(slug), repository, include_body=True)
if model == "project":
return self.get_project(slug)
if model == "devlog":
if not project:
raise LabyricornMcpError("VALIDATION_FAILED", "The project argument is required for devlog content.")
return self.get_devlog(project, slug)
except LabyricornMcpError:
raise
except Exception as exc:
raise self._translate(exc, not_found=True) from exc
raise LabyricornMcpError("INVALID_CONTENT_MODEL", "Supported content models are article, blog, project, and devlog.")
def _create_entry(self, model: str, slug: str, fields: dict[str, Any]) -> dict[str, Any]:
repository = self._article_repository() if model == "article" else self._blog_repository()
kicker = "Article" if model == "article" else "Blog"
values = {
"title": str(fields.get("title", "")),
"date": str(fields.get("date", date.today().isoformat())),
"updated": str(fields.get("updated", "")),
"author": str(fields.get("author", "")),
"tags": self._tag_input(fields.get("tags", "")),
"kicker": kicker,
"summary": str(fields.get("summary", "")),
"body": str(fields.get("body", "")),
"external_url": str(fields.get("external_url", "")),
}
if model == "article":
values["published_urls"] = self._lines_input(fields.get("published_urls", ""))
try:
entry = repository.create_entry(slug, values)
return self._entry_result(model, entry, repository, include_body=True)
except Exception as exc:
raise self._translate(exc) from exc
def _update_entry(self, model: str, slug: str, fields: dict[str, Any], expected_hash: str) -> dict[str, Any]:
repository = self._article_repository() if model == "article" else self._blog_repository()
try:
entry = repository.load_entry(slug)
actual_hash = _hash(entry.original_bytes)
if actual_hash != expected_hash:
raise LabyricornMcpError(
"FILE_CHANGED", "The content changed after it was read; reload it before updating.",
{"expected_sha256": expected_hash, "actual_sha256": actual_hash},
)
values = repository.values_for(entry.document)
for key, value in fields.items():
if key not in values:
raise LabyricornMcpError("VALIDATION_FAILED", f"Unsupported {model} field: {key}")
if key == "tags":
values[key] = self._tag_input(value)
elif key == "published_urls":
values[key] = self._lines_input(value)
else:
values[key] = str(value)
saved = repository.save_entry(entry, values)
return self._entry_result(model, saved, repository, include_body=True)
except LabyricornMcpError:
raise
except Exception as exc:
raise self._translate(exc, not_found=True) from exc
@staticmethod
def _tag_input(value: Any) -> str:
if isinstance(value, list):
return ", ".join(str(item) for item in value)
return str(value)
@staticmethod
def _lines_input(value: Any) -> str:
if isinstance(value, list):
return "\n".join(str(item) for item in value)
return str(value)
def create_article(self, slug: str, fields: dict[str, Any]) -> dict[str, Any]:
return self._create_entry("article", slug, fields)
def update_article(self, slug: str, fields: dict[str, Any], expected_hash: str) -> dict[str, Any]:
return self._update_entry("article", slug, fields, expected_hash)
def create_blog_post(self, slug: str, fields: dict[str, Any]) -> dict[str, Any]:
return self._create_entry("blog", slug, fields)
def update_blog_post(self, slug: str, fields: dict[str, Any], expected_hash: str) -> dict[str, Any]:
return self._update_entry("blog", slug, fields, expected_hash)
def list_tags(self) -> dict[str, Any]:
try:
tags = self._tag_repository().list_tags()
except Exception as exc:
raise self._translate(exc) from exc
return {
"count": len(tags),
"tags": [
{"slug": tag.slug, "title": tag.title, "summary": tag.summary, "sha256": _hash(tag.original_bytes)}
for tag in tags
],
}
def get_tag_usage(self, slug: str) -> dict[str, Any]:
usages: list[dict[str, str]] = []
for model in ("article", "blog"):
repository = self._article_repository() if model == "article" else self._blog_repository()
for entry in repository.list_entries():
tags = [part.strip() for part in re.split(r"[,\n]", entry.document.get("tags")) if part.strip()]
if slug in tags:
usages.append({"model": model, "slug": entry.slug})
for project in self.list_projects()["projects"]:
try:
snapshot = self._cached_project_snapshot(project["project_id"])
except LabyricornMcpError:
continue
project_record = snapshot["records"][".labyricorn/project/contents.lr"]
if slug in self._split(project_record.get("tags", "")):
usages.append({"model": "project", "slug": project["project_id"]})
for path, record in snapshot["records"].items():
if path.startswith(".labyricorn/devlog/") and path.count("/") == 3 and slug in self._split(record.get("tags", "")):
usages.append({"model": "devlog", "slug": Path(path).parent.name, "project": project["project_id"]})
tracked = any(tag["slug"] == slug for tag in self.list_tags()["tags"])
return {"slug": slug, "tracked": tracked, "count": len(usages), "usages": usages}
def create_tag(self, slug: str, title: str, summary: str) -> dict[str, Any]:
try:
tag = self._tag_repository().create_tag(slug, title, summary)
except Exception as exc:
raise self._translate(exc) from exc
return {"slug": tag.slug, "title": tag.title, "summary": tag.summary, "sha256": _hash(tag.original_bytes)}
def update_tag(self, slug: str, title: str, summary: str, expected_hash: str) -> dict[str, Any]:
repository = self._tag_repository()
try:
tag = repository.load_tag(slug)
actual = _hash(tag.original_bytes)
if actual != expected_hash:
raise LabyricornMcpError("FILE_CHANGED", "The tracked tag changed after it was read.", {"actual_sha256": actual})
saved = repository.save_tag(tag, title, summary)
except LabyricornMcpError:
raise
except Exception as exc:
raise self._translate(exc, not_found=True) from exc
return {"slug": saved.slug, "title": saved.title, "summary": saved.summary, "sha256": _hash(saved.original_bytes)}
def untrack_tag(self, slug: str, expected_hash: str) -> dict[str, Any]:
repository = self._tag_repository()
try:
tag = repository.load_tag(slug)
actual = _hash(tag.original_bytes)
if actual != expected_hash:
raise LabyricornMcpError("FILE_CHANGED", "The tracked tag changed after it was read.", {"actual_sha256": actual})
usage = self.get_tag_usage(slug)
repository.untrack_tag(tag)
except LabyricornMcpError:
raise
except Exception as exc:
raise self._translate(exc, not_found=True) from exc
return {"slug": slug, "untracked": True, "preserved_content_references": usage["count"]}
def list_projects(self) -> dict[str, Any]:
try:
sources = self._project_registry().list_sources()
except Exception as exc:
raise self._translate(exc) from exc
state = self._project_state()
projects = []
for source in sources:
cached = state.get("projects", {}).get(source.project_id, {})
projects.append(
{
"project_id": source.project_id,
"repository": source.web_url,
"branch": source.branch,
"featured_order": source.featured_order,
"cached_commit": cached.get("commit"),
"synchronized_at": cached.get("synchronized_at"),
"metadata": cached.get("metadata", {}),
}
)
return {"count": len(projects), "projects": projects}
def get_project(self, project_id: str) -> dict[str, Any]:
snapshot = self._cached_project_snapshot(project_id)
record = snapshot["records"][".labyricorn/project/contents.lr"]
return {
"model": "project",
"project_id": project_id,
"slug": project_id,
"fields": record,
"commit": snapshot["commit"],
"source": snapshot["source"],
"metadata": snapshot["state"].get("metadata", {}),
}
def list_project_devlogs(self, project_id: str) -> dict[str, Any]:
snapshot = self._cached_project_snapshot(project_id)
entries = []
for path, record in snapshot["records"].items():
pure = Path(path.replace("/", "\\")) if sys.platform == "win32" else Path(path)
if not path.startswith(".labyricorn/devlog/") or path.count("/") != 3:
continue
slug = pure.parent.name
entries.append(
{"model": "devlog", "project_id": project_id, "slug": slug, "fields": record, "commit": snapshot["commit"]}
)
entries.sort(key=lambda item: (item["fields"].get("date", ""), item["slug"]), reverse=True)
return {"project_id": project_id, "count": len(entries), "entries": entries}
def get_devlog(self, project_id: str, slug: str) -> dict[str, Any]:
for entry in self.list_project_devlogs(project_id)["entries"]:
if entry["slug"] == slug:
return entry
raise LabyricornMcpError("CONTENT_NOT_FOUND", "The requested project devlog entry does not exist.")
def list_project_sources(self) -> dict[str, Any]:
projects = self.list_projects()["projects"]
return {
"count": len(projects),
"sources": [
{key: value for key, value in project.items() if key != "metadata"}
for project in projects
],
}
def inspect_project_source(self, project_id: str) -> dict[str, Any]:
snapshot = self._cached_project_snapshot(project_id)
return {
"project_id": project_id,
"source": snapshot["source"],
"cached_commit": snapshot["commit"],
"validated_files": sorted(snapshot["files"]),
"records": snapshot["records"],
"network_accessed": False,
}
def validate_project_source(self, project_id: str) -> dict[str, Any]:
try:
source = self._project_registry().load_source(project_id)
readiness = self.modules["project"].check_devlog_readiness(source)
except Exception as exc:
raise self._translate(exc, not_found=True) from exc
return {"project_id": project_id, **asdict(readiness), "network_accessed": True, "repository_modified": False}
def _project_state(self) -> dict[str, Any]:
path = self.guard.resolve(".cache/project-sources/state.json")
if not path.is_file():
return {"version": 1, "projects": {}}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise LabyricornMcpError("VALIDATION_FAILED", "The local project cache state is malformed.") from exc
def _cached_project_snapshot(self, project_id: str) -> dict[str, Any]:
try:
source = self._project_registry().load_source(project_id)
except Exception as exc:
raise self._translate(exc, not_found=True) from exc
state = self._project_state().get("projects", {}).get(project_id)
if not state or not state.get("commit"):
raise LabyricornMcpError("CONTENT_NOT_FOUND", "No validated local snapshot is cached for this project.")
try:
import project_sources
except ModuleNotFoundError as exc:
raise LabyricornMcpError(
"RUNTIME_MISSING", "Inspecting cached project content requires the existing Lektor Python environment."
) from exc
mirror = self.guard.resolve(f".cache/project-sources/repos/{project_id}.git", must_exist=True)
try:
files, records = project_sources.validate_snapshot(source.importer_source(), mirror, state["commit"])
except Exception as exc:
raise LabyricornMcpError("VALIDATION_FAILED", f"The cached project snapshot failed validation: {exc}") from exc
return {
"source": {key: value for key, value in source.importer_source().items() if key != "api_url"},
"commit": state["commit"],
"state": state,
"files": files,
"records": records,
}
@staticmethod
def _split(value: str) -> list[str]:
return [part.strip() for part in re.split(r"[,\n]", value) if part.strip()]
def list_design_files(self, area: str | None = None) -> dict[str, Any]:
roots = DESIGN_ROOTS
if area:
if area not in DESIGN_ROOTS:
raise LabyricornMcpError("PATH_NOT_ALLOWED", "Area must be templates or assets/static.")
roots = (area,)
files = []
for root in roots:
base = self.guard.resolve(root, allowed_roots=DESIGN_ROOTS, must_exist=True)
for path in sorted(base.rglob("*")):
if not path.resolve().is_relative_to(self.guard.root):
raise LabyricornMcpError(
"PATH_OUTSIDE_REPOSITORY", "A design path resolves outside the repository."
)
if path.is_file() and not path.is_symlink():
files.append(
{
"path": self.guard.relative(path),
"size": path.stat().st_size,
"sha256": _hash(path.read_bytes()),
"text_readable": path.suffix.casefold() in DESIGN_SUFFIXES,
}
)
return {"count": len(files), "files": files}
def get_design_file(self, relative: str) -> dict[str, Any]:
path = self.guard.resolve(relative, allowed_roots=DESIGN_ROOTS, must_exist=True)
if path.suffix.casefold() not in DESIGN_SUFFIXES or not path.is_file():
raise LabyricornMcpError("PATH_NOT_ALLOWED", "Only allowlisted text design files can be read.")
try:
payload = path.read_bytes()
content = payload.decode("utf-8")
except (OSError, UnicodeError) as exc:
raise LabyricornMcpError("VALIDATION_FAILED", "The design file is not readable UTF-8 text.") from exc
return {"path": self.guard.relative(path), "content": content, "sha256": _hash(payload)}
def update_design_file(self, relative: str, content: str, expected_hash: str) -> dict[str, Any]:
path = self.guard.resolve(relative, allowed_roots=DESIGN_ROOTS, must_exist=True, reject_links=True)
if path.suffix.casefold() not in DESIGN_SUFFIXES or not path.is_file():
raise LabyricornMcpError("PATH_NOT_ALLOWED", "Only existing allowlisted text design files can be updated.")
current = path.read_bytes()
actual = _hash(current)
if actual != expected_hash:
raise LabyricornMcpError("FILE_CHANGED", "The design file changed after it was read.", {"actual_sha256": actual})
payload = content.encode("utf-8")
temporary: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb", prefix=f".{path.name}.labyricorn-mcp-", dir=path.parent, delete=False
) as output:
temporary = Path(output.name)
output.write(payload)
output.flush()
os.fsync(output.fileno())
if path.read_bytes() != current:
temporary.unlink(missing_ok=True)
raise LabyricornMcpError(
"FILE_CHANGED", "The design file changed while the update was being prepared."
)
os.chmod(temporary, path.stat().st_mode)
os.replace(temporary, path)
except LabyricornMcpError:
raise
except OSError as exc:
if temporary is not None:
temporary.unlink(missing_ok=True)
raise LabyricornMcpError("VALIDATION_FAILED", "The design file could not be updated.") from exc
return {"path": self.guard.relative(path), "sha256": _hash(payload), "changed": payload != current}