This commit is contained in:
@@ -72,6 +72,8 @@ assets/static/ CSS, filtering JavaScript, favicon, and static assets
|
|||||||
configs/project-sources.ini Approved public remote-project registry
|
configs/project-sources.ini Approved public remote-project registry
|
||||||
scripts/project_sources.py Remote-content validator and importer
|
scripts/project_sources.py Remote-content validator and importer
|
||||||
scripts/build_with_projects.py Isolated build/preview entry point
|
scripts/build_with_projects.py Isolated build/preview entry point
|
||||||
|
scripts/labyricorn_mcp_server.py Local stdio MCP launcher
|
||||||
|
labyricorn_mcp/ Repository-scoped MCP protocol and domain adapters
|
||||||
scripts/trigger_project_refresh.py Authenticated remote-project refresh client
|
scripts/trigger_project_refresh.py Authenticated remote-project refresh client
|
||||||
ops/deploy-labyricorn Versioned copy of the production deployment command
|
ops/deploy-labyricorn Versioned copy of the production deployment command
|
||||||
tests/ Importer validation tests
|
tests/ Importer validation tests
|
||||||
@@ -320,6 +322,103 @@ test -f build/index.html
|
|||||||
|
|
||||||
The project currently has no third-party Lektor packages or plugins.
|
The project currently has no third-party Lektor packages or plugins.
|
||||||
|
|
||||||
|
### Local Labyricorn MCP server
|
||||||
|
|
||||||
|
Labyricorn MCP is a local, stdio-based, repository-scoped management interface
|
||||||
|
for this checkout. An MCP-capable AI client launches it as a child process and
|
||||||
|
receives structured tools for repository information, models, content, tracked
|
||||||
|
tags, validated project snapshots, allowlisted design files, local validation,
|
||||||
|
and constrained Git operations.
|
||||||
|
|
||||||
|
It is not the Labyricorn website server, a deployment or remote-administration
|
||||||
|
service, a general filesystem MCP, a shell MCP, or a production infrastructure
|
||||||
|
controller. It creates no listener, HTTP endpoint, OAuth flow, persistent
|
||||||
|
service, scheduled job, or additional deployment mechanism. The boundary stays:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MCP-capable AI client
|
||||||
|
|
|
||||||
|
stdio
|
||||||
|
|
|
||||||
|
Labyricorn MCP process
|
||||||
|
|
|
||||||
|
local Labyricorn Lektor repository
|
||||||
|
|
|
||||||
|
explicit ordinary Git push
|
||||||
|
|
|
||||||
|
Gitea
|
||||||
|
|
|
||||||
|
existing external update/deployment system
|
||||||
|
|
|
||||||
|
live site
|
||||||
|
```
|
||||||
|
|
||||||
|
The implementation uses Python's standard library and the repository's existing
|
||||||
|
article, blog, tracked-tag, project-import, build, and Git helpers. It adds no
|
||||||
|
Python package dependency. Launch it from a terminal with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/labyricorn_mcp_server.py --repository /absolute/path/to/labyricorn-site
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows, use the existing Lektor pipx Python when available. Project-cache
|
||||||
|
inspection and local builds need Lektor in the server runtime; the usual pipx
|
||||||
|
location is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
%LOCALAPPDATA%\pipx\pipx\venvs\lektor\Scripts\python.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
For an MCP client that accepts the common `mcpServers` configuration shape, use
|
||||||
|
absolute paths. A Windows example is:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"labyricorn": {
|
||||||
|
"command": "C:\\Users\\YOUR-NAME\\AppData\\Local\\pipx\\pipx\\venvs\\lektor\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"C:\\absolute\\path\\to\\labyricorn-site\\scripts\\labyricorn_mcp_server.py",
|
||||||
|
"--repository",
|
||||||
|
"C:\\absolute\\path\\to\\labyricorn-site"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The client must launch the process directly; stdout is reserved exclusively for
|
||||||
|
newline-delimited MCP JSON-RPC messages and diagnostics go to stderr. The server
|
||||||
|
supports MCP protocol revisions `2025-11-25`, `2025-06-18`, `2025-03-26`, and
|
||||||
|
`2024-11-05`.
|
||||||
|
|
||||||
|
The focused v1 tool surface includes:
|
||||||
|
|
||||||
|
- repository/model/content reads plus safe article and blog create/update;
|
||||||
|
- tracked-tag list, usage, create, update, and explicit untrack;
|
||||||
|
- allowlisted project sources and locally cached snapshots validated through the
|
||||||
|
existing importer, plus an explicit networked source-validation check;
|
||||||
|
- inspection of templates and static text assets, with hash-guarded updates only
|
||||||
|
under `templates/` and `assets/static/`;
|
||||||
|
- the existing isolated project-aware build, unit tests, diff check, and a
|
||||||
|
consolidated validation result;
|
||||||
|
- Git status, diff, and log; clean fast-forward pull; reviewed-path-only commit;
|
||||||
|
and normal upstream push.
|
||||||
|
|
||||||
|
Mutations use repository-relative path containment and reject traversal,
|
||||||
|
out-of-tree resolution, and linked-path writes. Content and design updates
|
||||||
|
require the SHA-256 returned by the preceding read, so a concurrent human edit
|
||||||
|
causes `FILE_CHANGED` instead of being overwritten. Commits and pushes are
|
||||||
|
separate explicit actions. Commit accepts only reviewed changed paths, refuses
|
||||||
|
credential-like files and unrelated staged changes, and never pushes. Push
|
||||||
|
cannot force or rewrite history. A push from `main` requires an explicit
|
||||||
|
acknowledgement that Gitea's existing external site-update workflow may run.
|
||||||
|
The MCP itself never calls, inspects, restarts, or reconfigures production.
|
||||||
|
|
||||||
|
Content deletion and tracked-tag slug renaming are intentionally absent from v1.
|
||||||
|
Untracking a tag removes only its registry record and preserves every content
|
||||||
|
reference, matching the existing tracked-tag editor.
|
||||||
|
|
||||||
### Local management launcher
|
### Local management launcher
|
||||||
|
|
||||||
To check the checkout's Git synchronization state and open the focused desktop
|
To check the checkout's Git synchronization state and open the focused desktop
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Local, repository-scoped MCP management interface for Labyricorn."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Command-line entry point for the stdio MCP server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .errors import LabyricornMcpError
|
||||||
|
from .safety import RepositoryGuard
|
||||||
|
from .server import StdioMcpServer
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Run the local repository-scoped Labyricorn MCP server over stdio.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--repository",
|
||||||
|
help="Labyricorn repository root. Defaults to LABYRICORN_REPOSITORY, then the current directory.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--verbose", action="store_true", help="Write diagnostic detail to stderr.")
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
logging.basicConfig(
|
||||||
|
stream=sys.stderr,
|
||||||
|
level=logging.INFO if arguments.verbose else logging.WARNING,
|
||||||
|
format="labyricorn-mcp: %(levelname)s: %(message)s",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
guard = RepositoryGuard.discover(arguments.repository)
|
||||||
|
return StdioMcpServer(guard).serve_forever()
|
||||||
|
except LabyricornMcpError as exc:
|
||||||
|
print(f"labyricorn-mcp: ERROR: {exc.code}: {exc.message}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
"""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}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Structured errors safe to return to MCP clients."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LabyricornMcpError(Exception):
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
details: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.message
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {"code": self.code, "message": self.message}
|
||||||
|
if self.details:
|
||||||
|
result["details"] = self.details
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def require(condition: bool, code: str, message: str, **details: Any) -> None:
|
||||||
|
if not condition:
|
||||||
|
raise LabyricornMcpError(code, message, details)
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""Deliberately constrained Git operations for the local checkout."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .errors import LabyricornMcpError
|
||||||
|
from .safety import RepositoryGuard
|
||||||
|
|
||||||
|
|
||||||
|
PROHIBITED_COMMIT_PARTS = {".git", ".cache", ".lektor", "build", "dist"}
|
||||||
|
SENSITIVE_NAMES = {".env", ".netrc", "id_rsa", "id_ed25519"}
|
||||||
|
|
||||||
|
|
||||||
|
class GitOperations:
|
||||||
|
def __init__(self, guard: RepositoryGuard):
|
||||||
|
self.guard = guard
|
||||||
|
scripts = str(guard.root / "scripts")
|
||||||
|
if scripts not in sys.path:
|
||||||
|
sys.path.insert(0, scripts)
|
||||||
|
import git_repo
|
||||||
|
|
||||||
|
self.module = git_repo
|
||||||
|
self.repository = git_repo.GitRepository(guard.root)
|
||||||
|
|
||||||
|
def _run(self, *args: str, timeout: int = 30, allow_prompt: bool = False) -> Any:
|
||||||
|
try:
|
||||||
|
result = self.module._run_git(
|
||||||
|
self.guard.root, *args, check=False, timeout=timeout, allow_prompt=allow_prompt
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise LabyricornMcpError("GIT_ERROR", str(exc)) from exc
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = self.module._error_detail(result)
|
||||||
|
detail = re.sub(r"(https?://)[^/\s@]+@", r"\1", detail)
|
||||||
|
raise LabyricornMcpError("GIT_ERROR", detail)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _status_text(self) -> str:
|
||||||
|
return self._run("status", "--porcelain=v1", "--untracked-files=normal").stdout.rstrip()
|
||||||
|
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
text = self._status_text()
|
||||||
|
branch = self._run("symbolic-ref", "--quiet", "--short", "HEAD").stdout.strip()
|
||||||
|
commit = self._run("rev-parse", "HEAD").stdout.strip()
|
||||||
|
upstream_result = self.module._run_git(
|
||||||
|
self.guard.root,
|
||||||
|
"rev-parse",
|
||||||
|
"--abbrev-ref",
|
||||||
|
"--symbolic-full-name",
|
||||||
|
"@{upstream}",
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
upstream = upstream_result.stdout.strip() if upstream_result.returncode == 0 else None
|
||||||
|
entries = self._parse_status(text)
|
||||||
|
status_snapshot = json.dumps(
|
||||||
|
{
|
||||||
|
"branch": branch,
|
||||||
|
"commit": commit,
|
||||||
|
"upstream": upstream,
|
||||||
|
"porcelain": text,
|
||||||
|
"tracked_diff_sha256": hashlib.sha256(
|
||||||
|
self._run("diff", "--binary", "HEAD").stdout.encode("utf-8")
|
||||||
|
).hexdigest(),
|
||||||
|
"untracked_fingerprint": self._untracked_fingerprint(entries),
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"branch": branch,
|
||||||
|
"commit": commit,
|
||||||
|
"upstream": upstream,
|
||||||
|
"clean": not entries,
|
||||||
|
"changes": entries,
|
||||||
|
"status_sha256": hashlib.sha256(status_snapshot.encode("utf-8")).hexdigest(),
|
||||||
|
"main_push_warning": (
|
||||||
|
"Pushing main triggers the repository's external site update workflow. "
|
||||||
|
"The MCP performs only a Git push."
|
||||||
|
if branch == "main"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _untracked_fingerprint(self, entries: list[dict[str, str]]) -> list[tuple[str, int, int]]:
|
||||||
|
fingerprint: list[tuple[str, int, int]] = []
|
||||||
|
for entry in entries:
|
||||||
|
if entry["index"] != "?":
|
||||||
|
continue
|
||||||
|
relative = entry["path"].rstrip("/")
|
||||||
|
candidate = self.guard.resolve(relative)
|
||||||
|
if candidate.is_dir() and (candidate / ".git").exists():
|
||||||
|
stat = candidate.stat()
|
||||||
|
fingerprint.append((entry["path"], stat.st_size, stat.st_mtime_ns))
|
||||||
|
continue
|
||||||
|
paths = [candidate]
|
||||||
|
if candidate.is_dir():
|
||||||
|
paths = []
|
||||||
|
for directory, names, filenames in os.walk(candidate):
|
||||||
|
for name in names:
|
||||||
|
linked = Path(directory) / name
|
||||||
|
if (linked.is_symlink() or getattr(linked, "is_junction", lambda: False)()) and not linked.resolve().is_relative_to(self.guard.root):
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"PATH_OUTSIDE_REPOSITORY", "An untracked linked path resolves outside the repository."
|
||||||
|
)
|
||||||
|
names[:] = [name for name in names if name not in {".git", "__pycache__"}]
|
||||||
|
paths.extend(Path(directory) / filename for filename in filenames)
|
||||||
|
if len(paths) > 2_000:
|
||||||
|
break
|
||||||
|
for path in sorted(paths):
|
||||||
|
if (path.is_symlink() or getattr(path, "is_junction", lambda: False)()) and not path.resolve().is_relative_to(self.guard.root):
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"PATH_OUTSIDE_REPOSITORY", "An untracked linked path resolves outside the repository."
|
||||||
|
)
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
stat = path.stat()
|
||||||
|
fingerprint.append((self.guard.relative(path), stat.st_size, stat.st_mtime_ns))
|
||||||
|
return fingerprint
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_status(text: str) -> list[dict[str, str]]:
|
||||||
|
entries = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
if len(line) < 4:
|
||||||
|
continue
|
||||||
|
raw_path = line[3:]
|
||||||
|
if " -> " in raw_path:
|
||||||
|
original, path = raw_path.split(" -> ", 1)
|
||||||
|
entries.append({"index": line[0], "worktree": line[1], "path": path.strip('"'), "original_path": original.strip('"')})
|
||||||
|
else:
|
||||||
|
entries.append({"index": line[0], "worktree": line[1], "path": raw_path.strip('"')})
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def diff(self, *, staged: bool = False, path: str | None = None, max_chars: int = 100_000) -> dict[str, Any]:
|
||||||
|
args = ["diff"]
|
||||||
|
if staged:
|
||||||
|
args.append("--cached")
|
||||||
|
if path:
|
||||||
|
safe = self.guard.relative(self.guard.resolve(path))
|
||||||
|
args.extend(("--", safe))
|
||||||
|
output = self._run(*args).stdout
|
||||||
|
truncated = len(output) > max_chars
|
||||||
|
return {"staged": staged, "path": path, "diff": output[:max_chars], "truncated": truncated}
|
||||||
|
|
||||||
|
def log(self, limit: int = 20) -> dict[str, Any]:
|
||||||
|
if not isinstance(limit, int) or not 1 <= limit <= 100:
|
||||||
|
raise LabyricornMcpError("VALIDATION_FAILED", "Git log limit must be between 1 and 100.")
|
||||||
|
separator = "\x1f"
|
||||||
|
output = self._run(
|
||||||
|
"log", f"-{limit}", f"--format=%H{separator}%h{separator}%aI{separator}%an{separator}%s"
|
||||||
|
).stdout
|
||||||
|
commits = []
|
||||||
|
for line in output.splitlines():
|
||||||
|
parts = line.split(separator, 4)
|
||||||
|
if len(parts) == 5:
|
||||||
|
commits.append(dict(zip(("commit", "short_commit", "date", "author", "subject"), parts)))
|
||||||
|
return {"count": len(commits), "commits": commits}
|
||||||
|
|
||||||
|
def pull(self, expected_status_hash: str) -> dict[str, Any]:
|
||||||
|
before = self.status()
|
||||||
|
self._expect_status(before, expected_status_hash)
|
||||||
|
if not before["clean"]:
|
||||||
|
raise LabyricornMcpError("WORKTREE_CONFLICT", "A fast-forward pull requires a clean working tree.")
|
||||||
|
try:
|
||||||
|
state = self.repository.refresh()
|
||||||
|
updated = self.repository.pull(state)
|
||||||
|
except Exception as exc:
|
||||||
|
raise LabyricornMcpError("GIT_ERROR", str(exc)) from exc
|
||||||
|
return {"pulled": True, "branch": updated.branch, "commit": self._run("rev-parse", "HEAD").stdout.strip(), "status": self.status()}
|
||||||
|
|
||||||
|
def commit(self, message: str, paths: list[str], expected_status_hash: str) -> dict[str, Any]:
|
||||||
|
before = self.status()
|
||||||
|
self._expect_status(before, expected_status_hash)
|
||||||
|
message = " ".join(str(message).splitlines()).strip()
|
||||||
|
if not message:
|
||||||
|
raise LabyricornMcpError("VALIDATION_FAILED", "A non-empty commit message is required.")
|
||||||
|
if not isinstance(paths, list) or not paths:
|
||||||
|
raise LabyricornMcpError("VALIDATION_FAILED", "At least one reviewed changed path is required.")
|
||||||
|
changed = {entry["path"] for entry in before["changes"]}
|
||||||
|
untracked_directories = {
|
||||||
|
entry["path"] for entry in before["changes"]
|
||||||
|
if entry["index"] == "?" and entry["path"].endswith("/")
|
||||||
|
}
|
||||||
|
selected: list[str] = []
|
||||||
|
for value in paths:
|
||||||
|
path = self.guard.relative(self.guard.resolve(str(value), reject_links=True))
|
||||||
|
parts = set(Path(path).parts)
|
||||||
|
if parts & PROHIBITED_COMMIT_PARTS or Path(path).name.casefold() in SENSITIVE_NAMES:
|
||||||
|
raise LabyricornMcpError("PATH_NOT_ALLOWED", f"The path is not eligible for MCP commits: {path}")
|
||||||
|
if self._inside_nested_repository(path):
|
||||||
|
raise LabyricornMcpError("PATH_NOT_ALLOWED", f"Nested repositories are outside this MCP's commit scope: {path}")
|
||||||
|
candidate = self.guard.root / path
|
||||||
|
if candidate.exists() and not candidate.is_file():
|
||||||
|
raise LabyricornMcpError("PATH_NOT_ALLOWED", "Commit paths must identify individual files.")
|
||||||
|
summarized = any(path.startswith(directory) for directory in untracked_directories)
|
||||||
|
if path not in changed and not summarized:
|
||||||
|
raise LabyricornMcpError("WORKTREE_CONFLICT", f"The reviewed path is not a current working-tree change: {path}")
|
||||||
|
if path not in selected:
|
||||||
|
selected.append(path)
|
||||||
|
unrelated_staged = [
|
||||||
|
entry["path"] for entry in before["changes"] if entry["index"] not in {" ", "?"} and entry["path"] not in selected
|
||||||
|
]
|
||||||
|
if unrelated_staged:
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"WORKTREE_CONFLICT", "Unrelated staged changes must be reviewed outside this commit.",
|
||||||
|
{"staged_paths": unrelated_staged},
|
||||||
|
)
|
||||||
|
self._run("add", "--", *selected)
|
||||||
|
staged = [line for line in self._run("diff", "--cached", "--name-only").stdout.splitlines() if line]
|
||||||
|
if set(staged) != set(selected):
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"WORKTREE_CONFLICT", "Staging did not produce exactly the reviewed path set; no commit was created.",
|
||||||
|
{"reviewed_paths": selected, "staged_paths": staged},
|
||||||
|
)
|
||||||
|
self._run("commit", "-m", message, "--", *selected, timeout=60)
|
||||||
|
commit = self._run("rev-parse", "HEAD").stdout.strip()
|
||||||
|
return {"committed": True, "commit": commit, "message": message, "paths": selected, "status": self.status()}
|
||||||
|
|
||||||
|
def _inside_nested_repository(self, relative: str) -> bool:
|
||||||
|
current = self.guard.root
|
||||||
|
for part in Path(relative).parts[:-1]:
|
||||||
|
current /= part
|
||||||
|
if current != self.guard.root and (current / ".git").exists():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def push(self, expected_head: str, acknowledge_main_update: bool = False) -> dict[str, Any]:
|
||||||
|
before = self.status()
|
||||||
|
if before["commit"] != expected_head:
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"WORKTREE_CONFLICT", "HEAD changed after it was reviewed; inspect Git status again.",
|
||||||
|
{"actual_head": before["commit"]},
|
||||||
|
)
|
||||||
|
if before["branch"] == "main" and not acknowledge_main_update:
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"MAIN_PUSH_ACK_REQUIRED",
|
||||||
|
"Pushing main triggers the repository's external site update workflow. Set acknowledge_main_update=true to confirm the Git push.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
state = self.repository.refresh()
|
||||||
|
if state.branch != before["branch"]:
|
||||||
|
raise LabyricornMcpError("WORKTREE_CONFLICT", "The branch changed while preparing the push.")
|
||||||
|
updated = self.repository.push(state)
|
||||||
|
except LabyricornMcpError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise LabyricornMcpError("GIT_ERROR", str(exc)) from exc
|
||||||
|
return {
|
||||||
|
"pushed": True,
|
||||||
|
"branch": updated.branch,
|
||||||
|
"commit": expected_head,
|
||||||
|
"external_update_may_run": updated.branch == "main",
|
||||||
|
"production_contacted_by_mcp": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _expect_status(status: dict[str, Any], expected: str) -> None:
|
||||||
|
if status["status_sha256"] != expected:
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"WORKTREE_CONFLICT", "The working tree changed after it was reviewed; inspect Git status again.",
|
||||||
|
{"actual_status_sha256": status["status_sha256"]},
|
||||||
|
)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Repository discovery and filesystem containment."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path, PurePath
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from .errors import LabyricornMcpError
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryGuard:
|
||||||
|
"""Resolve every path against one verified Labyricorn Git checkout."""
|
||||||
|
|
||||||
|
REQUIRED_PATHS = (
|
||||||
|
"Labyricorn.lektorproject",
|
||||||
|
"content/articles/contents.lr",
|
||||||
|
"content/blog/contents.lr",
|
||||||
|
"content/projects/contents.lr",
|
||||||
|
"content/tags/contents.lr",
|
||||||
|
"models/entry.ini",
|
||||||
|
"models/project.ini",
|
||||||
|
"models/devlog-entry.ini",
|
||||||
|
"configs/project-sources.ini",
|
||||||
|
"scripts/build_with_projects.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, root: Path):
|
||||||
|
try:
|
||||||
|
self.root = root.expanduser().resolve(strict=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"REPOSITORY_NOT_FOUND", "The configured repository directory does not exist."
|
||||||
|
) from exc
|
||||||
|
if not self.root.is_dir():
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"REPOSITORY_NOT_FOUND", "The configured repository path is not a directory."
|
||||||
|
)
|
||||||
|
self._validate_repository()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def discover(cls, configured: str | None = None) -> "RepositoryGuard":
|
||||||
|
selected = configured or os.environ.get("LABYRICORN_REPOSITORY") or os.getcwd()
|
||||||
|
return cls(Path(selected))
|
||||||
|
|
||||||
|
def _validate_repository(self) -> None:
|
||||||
|
missing = [relative for relative in self.REQUIRED_PATHS if not (self.root / relative).is_file()]
|
||||||
|
if missing:
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"INVALID_LABYRICORN_REPOSITORY",
|
||||||
|
"The selected directory is not a compatible Labyricorn repository.",
|
||||||
|
{"missing": missing},
|
||||||
|
)
|
||||||
|
escaped = [
|
||||||
|
relative
|
||||||
|
for relative in self.REQUIRED_PATHS
|
||||||
|
if not (self.root / relative).resolve().is_relative_to(self.root)
|
||||||
|
]
|
||||||
|
if escaped:
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"INVALID_LABYRICORN_REPOSITORY",
|
||||||
|
"Required Labyricorn paths must not resolve outside the repository.",
|
||||||
|
{"escaped": escaped},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--show-toplevel"],
|
||||||
|
cwd=self.root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=15,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"INVALID_LABYRICORN_REPOSITORY", "The selected directory is not an accessible Git repository."
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
git_root = Path(result.stdout.strip()).resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
git_root = Path()
|
||||||
|
if result.returncode != 0 or git_root != self.root:
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"INVALID_LABYRICORN_REPOSITORY",
|
||||||
|
"The selected directory must be the root of the Labyricorn Git repository.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
self,
|
||||||
|
relative: str,
|
||||||
|
*,
|
||||||
|
allowed_roots: tuple[str, ...] = (),
|
||||||
|
must_exist: bool = False,
|
||||||
|
reject_links: bool = False,
|
||||||
|
) -> Path:
|
||||||
|
if not isinstance(relative, str) or not relative.strip():
|
||||||
|
raise LabyricornMcpError("INVALID_PATH", "A non-empty repository-relative path is required.")
|
||||||
|
raw = PurePath(relative)
|
||||||
|
if raw.is_absolute() or raw.drive or ".." in raw.parts:
|
||||||
|
raise LabyricornMcpError("PATH_OUTSIDE_REPOSITORY", "Only repository-relative paths are allowed.")
|
||||||
|
candidate = self.root.joinpath(*raw.parts)
|
||||||
|
try:
|
||||||
|
resolved = candidate.resolve(strict=must_exist)
|
||||||
|
except OSError as exc:
|
||||||
|
raise LabyricornMcpError("CONTENT_NOT_FOUND", "The requested repository path does not exist.") from exc
|
||||||
|
if not resolved.is_relative_to(self.root):
|
||||||
|
raise LabyricornMcpError("PATH_OUTSIDE_REPOSITORY", "The requested path resolves outside the repository.")
|
||||||
|
if allowed_roots and not any(
|
||||||
|
resolved.is_relative_to((self.root / allowed).resolve()) for allowed in allowed_roots
|
||||||
|
):
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"PATH_NOT_ALLOWED", "The requested path is outside the allowlisted repository areas.",
|
||||||
|
{"allowed_roots": list(allowed_roots)},
|
||||||
|
)
|
||||||
|
if reject_links:
|
||||||
|
current = self.root
|
||||||
|
for part in raw.parts:
|
||||||
|
current = current / part
|
||||||
|
if current.exists() and (current.is_symlink() or getattr(current, "is_junction", lambda: False)()):
|
||||||
|
raise LabyricornMcpError("PATH_LINK_REJECTED", "Linked paths cannot be modified.")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
def relative(self, path: Path) -> str:
|
||||||
|
return path.resolve().relative_to(self.root).as_posix()
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Dependency-free MCP stdio transport for the local Labyricorn repository."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import Any, TextIO
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .errors import LabyricornMcpError
|
||||||
|
from .safety import RepositoryGuard
|
||||||
|
from .tools import ToolRegistry
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_PROTOCOLS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
|
||||||
|
LATEST_PROTOCOL = SUPPORTED_PROTOCOLS[0]
|
||||||
|
|
||||||
|
|
||||||
|
class StdioMcpServer:
|
||||||
|
def __init__(self, guard: RepositoryGuard, *, input_stream: TextIO | None = None, output_stream: TextIO | None = None):
|
||||||
|
self.guard = guard
|
||||||
|
self.registry = ToolRegistry(guard)
|
||||||
|
self.input = input_stream or sys.stdin
|
||||||
|
self.output = output_stream or sys.stdout
|
||||||
|
self.initialized = False
|
||||||
|
self.client_initialized = False
|
||||||
|
self.protocol_version: str | None = None
|
||||||
|
self.log = logging.getLogger("labyricorn_mcp")
|
||||||
|
|
||||||
|
def serve_forever(self) -> int:
|
||||||
|
self.log.info("server started for repository %s", self.guard.root)
|
||||||
|
for raw_line in self.input:
|
||||||
|
line = raw_line.rstrip("\r\n")
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
message = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self._write_error(None, -32700, "Parse error")
|
||||||
|
continue
|
||||||
|
if not isinstance(message, dict) or isinstance(message.get("id"), (dict, list)):
|
||||||
|
self._write_error(message.get("id") if isinstance(message, dict) else None, -32600, "Invalid Request")
|
||||||
|
continue
|
||||||
|
self._handle(message)
|
||||||
|
self.log.info("server stopped after stdin closed")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _handle(self, message: dict[str, Any]) -> None:
|
||||||
|
method = message.get("method")
|
||||||
|
request_id = message.get("id")
|
||||||
|
is_notification = "id" not in message
|
||||||
|
if message.get("jsonrpc") != "2.0" or not isinstance(method, str):
|
||||||
|
if not is_notification:
|
||||||
|
self._write_error(request_id, -32600, "Invalid Request")
|
||||||
|
return
|
||||||
|
params = message.get("params", {})
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
if not is_notification:
|
||||||
|
self._write_error(request_id, -32602, "Invalid params")
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_notification:
|
||||||
|
if method == "notifications/initialized" and self.initialized:
|
||||||
|
self.client_initialized = True
|
||||||
|
return
|
||||||
|
|
||||||
|
if method == "initialize":
|
||||||
|
self._initialize(request_id, params)
|
||||||
|
return
|
||||||
|
if not self.initialized:
|
||||||
|
self._write_error(request_id, -32002, "Server is not initialized")
|
||||||
|
return
|
||||||
|
if method == "ping":
|
||||||
|
self._write_result(request_id, {})
|
||||||
|
elif method == "tools/list":
|
||||||
|
self._write_result(request_id, {"tools": self.registry.tools})
|
||||||
|
elif method == "tools/call":
|
||||||
|
self._call_tool(request_id, params)
|
||||||
|
else:
|
||||||
|
self._write_error(request_id, -32601, "Method not found")
|
||||||
|
|
||||||
|
def _initialize(self, request_id: Any, params: dict[str, Any]) -> None:
|
||||||
|
if self.initialized:
|
||||||
|
self._write_error(request_id, -32600, "Server is already initialized")
|
||||||
|
return
|
||||||
|
requested = params.get("protocolVersion")
|
||||||
|
self.protocol_version = requested if requested in SUPPORTED_PROTOCOLS else LATEST_PROTOCOL
|
||||||
|
self.initialized = True
|
||||||
|
self._write_result(
|
||||||
|
request_id,
|
||||||
|
{
|
||||||
|
"protocolVersion": self.protocol_version,
|
||||||
|
"capabilities": {"tools": {"listChanged": False}},
|
||||||
|
"serverInfo": {"name": "labyricorn-mcp", "version": __version__},
|
||||||
|
"instructions": (
|
||||||
|
"This server manages only the configured local Labyricorn Git checkout. "
|
||||||
|
"It has no production administration capability. Commits and pushes are separate explicit tools."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _call_tool(self, request_id: Any, params: dict[str, Any]) -> None:
|
||||||
|
name = params.get("name")
|
||||||
|
if not isinstance(name, str):
|
||||||
|
self._write_error(request_id, -32602, "Tool name is required")
|
||||||
|
return
|
||||||
|
self.log.info("tool invocation: %s", name)
|
||||||
|
try:
|
||||||
|
result = self.registry.call(name, params.get("arguments"))
|
||||||
|
except LabyricornMcpError as exc:
|
||||||
|
self.log.warning("tool rejected: %s code=%s", name, exc.code)
|
||||||
|
payload = {"error": exc.as_dict()}
|
||||||
|
self._write_result(
|
||||||
|
request_id,
|
||||||
|
{
|
||||||
|
"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}],
|
||||||
|
"structuredContent": payload,
|
||||||
|
"isError": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("unexpected tool failure: %s", name)
|
||||||
|
payload = {"error": {"code": "INTERNAL_ERROR", "message": "The tool failed unexpectedly; see local stderr diagnostics."}}
|
||||||
|
self._write_result(
|
||||||
|
request_id,
|
||||||
|
{
|
||||||
|
"content": [{"type": "text", "text": json.dumps(payload)}],
|
||||||
|
"structuredContent": payload,
|
||||||
|
"isError": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._write_result(
|
||||||
|
request_id,
|
||||||
|
{
|
||||||
|
"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, default=str)}],
|
||||||
|
"structuredContent": result,
|
||||||
|
"isError": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _write_result(self, request_id: Any, result: dict[str, Any]) -> None:
|
||||||
|
self._write({"jsonrpc": "2.0", "id": request_id, "result": result})
|
||||||
|
|
||||||
|
def _write_error(self, request_id: Any, code: int, message: str) -> None:
|
||||||
|
self._write({"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}})
|
||||||
|
|
||||||
|
def _write(self, message: dict[str, Any]) -> None:
|
||||||
|
self.output.write(json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||||
|
self.output.flush()
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""MCP tool declarations and dispatch."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import configparser
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from .domain import LabyricornDomain
|
||||||
|
from .errors import LabyricornMcpError
|
||||||
|
from .gitops import GitOperations
|
||||||
|
from .safety import RepositoryGuard
|
||||||
|
from .validation import ValidationOperations
|
||||||
|
|
||||||
|
|
||||||
|
Handler = Callable[[dict[str, Any]], dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
def obj(properties: dict[str, Any] | None = None, required: list[str] | None = None) -> dict[str, Any]:
|
||||||
|
schema: dict[str, Any] = {"type": "object", "properties": properties or {}, "additionalProperties": False}
|
||||||
|
if required:
|
||||||
|
schema["required"] = required
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def string(description: str = "") -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {"type": "string"}
|
||||||
|
if description:
|
||||||
|
result["description"] = description
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def boolean(description: str = "") -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {"type": "boolean"}
|
||||||
|
if description:
|
||||||
|
result["description"] = description
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def integer(description: str = "") -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {"type": "integer"}
|
||||||
|
if description:
|
||||||
|
result["description"] = description
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def string_array(description: str = "") -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {"type": "array", "items": {"type": "string"}}
|
||||||
|
if description:
|
||||||
|
result["description"] = description
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class ToolRegistry:
|
||||||
|
def __init__(self, guard: RepositoryGuard):
|
||||||
|
self.guard = guard
|
||||||
|
self.domain = LabyricornDomain(guard)
|
||||||
|
self.git = GitOperations(guard)
|
||||||
|
self.validation = ValidationOperations(guard, self.git)
|
||||||
|
self._handlers: dict[str, Handler] = {}
|
||||||
|
self._tools: list[dict[str, Any]] = []
|
||||||
|
self._register_tools()
|
||||||
|
|
||||||
|
def _add(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
schema: dict[str, Any],
|
||||||
|
handler: Handler,
|
||||||
|
*,
|
||||||
|
read_only: bool,
|
||||||
|
destructive: bool = False,
|
||||||
|
idempotent: bool = False,
|
||||||
|
open_world: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self._handlers[name] = handler
|
||||||
|
self._tools.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"title": name.replace("_", " ").title(),
|
||||||
|
"description": description,
|
||||||
|
"inputSchema": schema,
|
||||||
|
"annotations": {
|
||||||
|
"readOnlyHint": read_only,
|
||||||
|
"destructiveHint": destructive,
|
||||||
|
"idempotentHint": idempotent,
|
||||||
|
"openWorldHint": open_world,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tools(self) -> list[dict[str, Any]]:
|
||||||
|
return list(self._tools)
|
||||||
|
|
||||||
|
def call(self, name: str, arguments: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
handler = self._handlers.get(name)
|
||||||
|
if handler is None:
|
||||||
|
raise LabyricornMcpError("TOOL_NOT_FOUND", f"Unknown Labyricorn MCP tool: {name}")
|
||||||
|
if arguments is None:
|
||||||
|
arguments = {}
|
||||||
|
if not isinstance(arguments, dict):
|
||||||
|
raise LabyricornMcpError("VALIDATION_FAILED", "Tool arguments must be an object.")
|
||||||
|
tool = next(item for item in self._tools if item["name"] == name)
|
||||||
|
self._validate_arguments(tool["inputSchema"], arguments)
|
||||||
|
return handler(arguments)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_arguments(schema: dict[str, Any], arguments: dict[str, Any]) -> None:
|
||||||
|
properties = schema.get("properties", {})
|
||||||
|
unknown = sorted(set(arguments) - set(properties))
|
||||||
|
if unknown:
|
||||||
|
raise LabyricornMcpError("VALIDATION_FAILED", "Unknown tool arguments were supplied.", {"unknown": unknown})
|
||||||
|
missing = [name for name in schema.get("required", []) if name not in arguments]
|
||||||
|
if missing:
|
||||||
|
raise LabyricornMcpError("VALIDATION_FAILED", "Required tool arguments are missing.", {"missing": missing})
|
||||||
|
type_map = {
|
||||||
|
"string": str,
|
||||||
|
"boolean": bool,
|
||||||
|
"integer": int,
|
||||||
|
"object": dict,
|
||||||
|
"array": list,
|
||||||
|
}
|
||||||
|
for name, value in arguments.items():
|
||||||
|
expected_name = properties[name].get("type")
|
||||||
|
expected = type_map.get(expected_name)
|
||||||
|
if expected and (not isinstance(value, expected) or expected is int and isinstance(value, bool)):
|
||||||
|
raise LabyricornMcpError("VALIDATION_FAILED", f"Argument {name!r} must be {expected_name}.")
|
||||||
|
if expected is list and properties[name].get("items", {}).get("type") == "string":
|
||||||
|
if not all(isinstance(item, str) for item in value):
|
||||||
|
raise LabyricornMcpError("VALIDATION_FAILED", f"Argument {name!r} must contain only strings.")
|
||||||
|
|
||||||
|
def _site_info(self) -> dict[str, Any]:
|
||||||
|
project_file = self.guard.root / "Labyricorn.lektorproject"
|
||||||
|
parser = configparser.ConfigParser(interpolation=None)
|
||||||
|
parser.read(project_file, encoding="utf-8")
|
||||||
|
status = self.git.status()
|
||||||
|
return {
|
||||||
|
"repository_root": str(self.guard.root),
|
||||||
|
"branch": status["branch"],
|
||||||
|
"commit": status["commit"],
|
||||||
|
"working_tree_clean": status["clean"],
|
||||||
|
"changed_paths": [change["path"] for change in status["changes"]],
|
||||||
|
"project": dict(parser.items("project")),
|
||||||
|
"project_file": project_file.name,
|
||||||
|
"models": sorted(path.stem for path in (self.guard.root / "models").glob("*.ini")),
|
||||||
|
"remote_project_sources": self.domain.list_project_sources()["count"],
|
||||||
|
"scope": "local repository only",
|
||||||
|
"production_access": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _mutation(self, operation: Callable[[], dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
before = self.git.status()
|
||||||
|
result = operation()
|
||||||
|
after = self.git.status()
|
||||||
|
result["worktree_context"] = {
|
||||||
|
"before_status_sha256": before["status_sha256"],
|
||||||
|
"before_changed_paths": [item["path"] for item in before["changes"]],
|
||||||
|
"after_status_sha256": after["status_sha256"],
|
||||||
|
"after_changed_paths": [item["path"] for item in after["changes"]],
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _register_tools(self) -> None:
|
||||||
|
empty = obj()
|
||||||
|
fields_schema = {"type": "object", "description": "Model field values to create or update."}
|
||||||
|
self._add("site_info", "Describe the configured local Labyricorn repository; this is not production status.", empty, lambda _a: self._site_info(), read_only=True)
|
||||||
|
self._add("site_structure", "Describe the local content, model, design, build, and remote-project organization.", empty, lambda _a: self.domain.site_structure(), read_only=True)
|
||||||
|
self._add("describe_model", "Read a Lektor model definition from models/*.ini.", obj({"model": string()}, ["model"]), lambda a: self.domain.describe_model(a["model"]), read_only=True)
|
||||||
|
self._add(
|
||||||
|
"list_content",
|
||||||
|
"List article, blog, project, and devlog content with simple optional filters.",
|
||||||
|
obj({key: string() for key in ("model", "tag", "project", "date", "slug", "search")}),
|
||||||
|
lambda a: self.domain.list_content(a),
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
self._add(
|
||||||
|
"get_content",
|
||||||
|
"Get one article, blog post, project, or project devlog record.",
|
||||||
|
obj({"model": string(), "slug": string(), "project": string()}, ["model", "slug"]),
|
||||||
|
lambda a: self.domain.get_content(a["model"], a["slug"], a.get("project")),
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
for name, description, handler in (
|
||||||
|
("create_article", "Create a native article entry in content/articles.", lambda a: self._mutation(lambda: self.domain.create_article(a["slug"], a["fields"]))),
|
||||||
|
("create_blog_post", "Create a native blog entry in content/blog.", lambda a: self._mutation(lambda: self.domain.create_blog_post(a["slug"], a["fields"]))),
|
||||||
|
):
|
||||||
|
self._add(name, description, obj({"slug": string(), "fields": fields_schema}, ["slug", "fields"]), handler, read_only=False)
|
||||||
|
for name, description, handler in (
|
||||||
|
("update_article", "Update an article only if its previously returned SHA-256 still matches.", lambda a: self._mutation(lambda: self.domain.update_article(a["slug"], a["fields"], a["expected_sha256"]))),
|
||||||
|
("update_blog_post", "Update a blog post only if its previously returned SHA-256 still matches.", lambda a: self._mutation(lambda: self.domain.update_blog_post(a["slug"], a["fields"], a["expected_sha256"]))),
|
||||||
|
):
|
||||||
|
self._add(name, description, obj({"slug": string(), "fields": fields_schema, "expected_sha256": string()}, ["slug", "fields", "expected_sha256"]), handler, read_only=False, idempotent=True)
|
||||||
|
self._add("list_tags", "List tracked tags from content/tags.", empty, lambda _a: self.domain.list_tags(), read_only=True)
|
||||||
|
self._add("get_tag_usage", "Find article, blog, cached project, and cached devlog uses of a tag slug.", obj({"slug": string()}, ["slug"]), lambda a: self.domain.get_tag_usage(a["slug"]), read_only=True)
|
||||||
|
self._add("create_tag", "Create a tracked tag without rewriting existing content references.", obj({"slug": string(), "title": string(), "summary": string()}, ["slug", "title", "summary"]), lambda a: self._mutation(lambda: self.domain.create_tag(a["slug"], a["title"], a["summary"])), read_only=False)
|
||||||
|
self._add("update_tag", "Update tracked-tag display data if its SHA-256 still matches; slug renaming is not supported.", obj({"slug": string(), "title": string(), "summary": string(), "expected_sha256": string()}, ["slug", "title", "summary", "expected_sha256"]), lambda a: self._mutation(lambda: self.domain.update_tag(a["slug"], a["title"], a["summary"], a["expected_sha256"])), read_only=False, idempotent=True)
|
||||||
|
self._add("untrack_tag", "Remove only a tracked tag record, preserving every content reference.", obj({"slug": string(), "expected_sha256": string()}, ["slug", "expected_sha256"]), lambda a: self._mutation(lambda: self.domain.untrack_tag(a["slug"], a["expected_sha256"])), read_only=False, destructive=True)
|
||||||
|
self._add("list_projects", "List allowlisted remote projects and local last-known-good metadata.", empty, lambda _a: self.domain.list_projects(), read_only=True)
|
||||||
|
self._add("get_project", "Read a project record from its validated local cached mirror.", obj({"project_id": string()}, ["project_id"]), lambda a: self.domain.get_project(a["project_id"]), read_only=True)
|
||||||
|
self._add("list_project_devlogs", "List devlog records from a project's validated local cached mirror.", obj({"project_id": string()}, ["project_id"]), lambda a: self.domain.list_project_devlogs(a["project_id"]), read_only=True)
|
||||||
|
self._add("get_devlog", "Read one devlog record from a validated local cached mirror.", obj({"project_id": string(), "slug": string()}, ["project_id", "slug"]), lambda a: self.domain.get_devlog(a["project_id"], a["slug"]), read_only=True)
|
||||||
|
self._add("list_project_sources", "List the repository's allowlisted public project sources.", empty, lambda _a: self.domain.list_project_sources(), read_only=True)
|
||||||
|
self._add("inspect_project_source", "Validate and inspect an existing local cached project snapshot without network access.", obj({"project_id": string()}, ["project_id"]), lambda a: self.domain.inspect_project_source(a["project_id"]), read_only=True)
|
||||||
|
self._add("validate_project_source", "Run the existing read-only public-provider and disposable-mirror validation for one allowlisted source.", obj({"project_id": string()}, ["project_id"]), lambda a: self.domain.validate_project_source(a["project_id"]), read_only=True, open_world=True)
|
||||||
|
self._add("list_design_files", "List allowlisted template and static text design files.", obj({"area": string()}), lambda a: self.domain.list_design_files(a.get("area")), read_only=True)
|
||||||
|
self._add("get_design_file", "Read an allowlisted template or static text design file with a concurrency hash.", obj({"path": string()}, ["path"]), lambda a: self.domain.get_design_file(a["path"]), read_only=True)
|
||||||
|
self._add("update_design_file", "Update an existing allowlisted template or static text file only if its hash still matches.", obj({"path": string(), "content": string(), "expected_sha256": string()}, ["path", "content", "expected_sha256"]), lambda a: self._mutation(lambda: self.domain.update_design_file(a["path"], a["content"], a["expected_sha256"])), read_only=False, idempotent=True)
|
||||||
|
self._add("build_site", "Run the repository's isolated build_with_projects.py validation; output is temporary and production is not contacted.", empty, lambda _a: self.validation.build_site(), read_only=False, idempotent=True, open_world=True)
|
||||||
|
self._add("run_tests", "Run the repository unittest suite with the server's Python runtime.", empty, lambda _a: self.validation.run_tests(), read_only=False, idempotent=True)
|
||||||
|
self._add("check_diff", "Run git diff --check and report changed files.", empty, lambda _a: self.validation.check_diff(), read_only=True)
|
||||||
|
self._add("validate_changes", "Consolidate the supported local build, tests, and diff checks.", empty, lambda _a: self.validation.validate_changes(), read_only=False, idempotent=True, open_world=True)
|
||||||
|
self._add("git_status", "Read branch, HEAD, upstream, working-tree changes, and a status concurrency hash without fetching.", empty, lambda _a: self.git.status(), read_only=True)
|
||||||
|
self._add("git_diff", "Read a constrained Git diff, optionally staged or limited to one repository path.", obj({"staged": boolean(), "path": string()}), lambda a: self.git.diff(staged=a.get("staged", False), path=a.get("path")), read_only=True)
|
||||||
|
self._add("git_log", "Read recent local Git history (maximum 100 commits).", obj({"limit": integer()}), lambda a: self.git.log(a.get("limit", 20)), read_only=True)
|
||||||
|
self._add("git_pull", "Perform only a clean, reviewed, fast-forward pull through the existing Git safety helper.", obj({"expected_status_sha256": string()}, ["expected_status_sha256"]), lambda a: self.git.pull(a["expected_status_sha256"]), read_only=False, open_world=True)
|
||||||
|
self._add("commit_changes", "Commit only explicitly listed reviewed paths; never pushes and rejects unrelated staged changes.", obj({"message": string(), "paths": string_array(), "expected_status_sha256": string()}, ["message", "paths", "expected_status_sha256"]), lambda a: self.git.commit(a["message"], a["paths"], a["expected_status_sha256"]), read_only=False)
|
||||||
|
self._add("push_changes", "Push the reviewed HEAD normally to its configured origin upstream; force push is impossible. Pushing main requires acknowledgement.", obj({"expected_head": string(), "acknowledge_main_update": boolean()}, ["expected_head"]), lambda a: self.git.push(a["expected_head"], a.get("acknowledge_main_update", False)), read_only=False, open_world=True)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Structured wrappers around the repository's supported validation commands."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from .errors import LabyricornMcpError
|
||||||
|
from .gitops import GitOperations
|
||||||
|
from .safety import RepositoryGuard
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationOperations:
|
||||||
|
BUILD_SOURCE_DIRECTORIES = ("assets", "configs", "content", "models", "scripts", "templates")
|
||||||
|
|
||||||
|
def __init__(self, guard: RepositoryGuard, git: GitOperations):
|
||||||
|
self.guard = guard
|
||||||
|
self.git = git
|
||||||
|
|
||||||
|
def _run(self, command: list[str], *, timeout: int) -> dict[str, Any]:
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=self.guard.root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=timeout,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
return {
|
||||||
|
"passed": False,
|
||||||
|
"returncode": None,
|
||||||
|
"duration_seconds": round(time.monotonic() - started, 3),
|
||||||
|
"stdout": (exc.stdout or "")[-100_000:] if isinstance(exc.stdout, str) else "",
|
||||||
|
"stderr": "Validation timed out.",
|
||||||
|
"timed_out": True,
|
||||||
|
}
|
||||||
|
except OSError as exc:
|
||||||
|
raise LabyricornMcpError("VALIDATION_FAILED", f"Validation could not start: {exc}") from exc
|
||||||
|
return {
|
||||||
|
"passed": result.returncode == 0,
|
||||||
|
"returncode": result.returncode,
|
||||||
|
"duration_seconds": round(time.monotonic() - started, 3),
|
||||||
|
"stdout": result.stdout[-100_000:],
|
||||||
|
"stderr": result.stderr[-100_000:],
|
||||||
|
"truncated": len(result.stdout) > 100_000 or len(result.stderr) > 100_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _lektor_executable(self) -> str:
|
||||||
|
adjacent = Path(sys.executable).with_name("lektor.exe" if sys.platform == "win32" else "lektor")
|
||||||
|
if adjacent.is_file():
|
||||||
|
return str(adjacent)
|
||||||
|
discovered = shutil.which("lektor")
|
||||||
|
if discovered:
|
||||||
|
return discovered
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"RUNTIME_MISSING",
|
||||||
|
"Lektor is unavailable. Launch the MCP with the existing Lektor pipx Python environment or put lektor on PATH.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_site(self) -> dict[str, Any]:
|
||||||
|
cache = self.guard.resolve(".cache")
|
||||||
|
cache.mkdir(exist_ok=True)
|
||||||
|
temporary = cache / f"labyricorn-mcp-build-{uuid.uuid4().hex}"
|
||||||
|
source = temporary / "source"
|
||||||
|
output = temporary / "output"
|
||||||
|
source.mkdir(parents=True)
|
||||||
|
try:
|
||||||
|
for name in self.BUILD_SOURCE_DIRECTORIES:
|
||||||
|
origin = self.guard.resolve(name, must_exist=True)
|
||||||
|
if origin.is_dir():
|
||||||
|
self._validate_build_tree(origin)
|
||||||
|
shutil.copytree(
|
||||||
|
origin,
|
||||||
|
source / name,
|
||||||
|
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
|
||||||
|
)
|
||||||
|
shutil.copy2(
|
||||||
|
self.guard.root / "Labyricorn.lektorproject",
|
||||||
|
source / "Labyricorn.lektorproject",
|
||||||
|
)
|
||||||
|
command = [
|
||||||
|
sys.executable,
|
||||||
|
str(source / "scripts" / "build_with_projects.py"),
|
||||||
|
"--site-root",
|
||||||
|
str(source),
|
||||||
|
"--cache-path",
|
||||||
|
str(self.guard.resolve(".cache/project-sources")),
|
||||||
|
"--output-path",
|
||||||
|
str(output),
|
||||||
|
"--lektor",
|
||||||
|
self._lektor_executable(),
|
||||||
|
]
|
||||||
|
result = self._run(command, timeout=600)
|
||||||
|
result["index_created"] = (output / "index.html").is_file()
|
||||||
|
result["output_retained"] = False
|
||||||
|
result["passed"] = result["passed"] and result["index_created"]
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(temporary, ignore_errors=True)
|
||||||
|
|
||||||
|
def _validate_build_tree(self, root: Path) -> None:
|
||||||
|
if not root.resolve().is_relative_to(self.guard.root):
|
||||||
|
raise LabyricornMcpError("PATH_OUTSIDE_REPOSITORY", "A build source root resolves outside the repository.")
|
||||||
|
for path in root.rglob("*"):
|
||||||
|
if not path.resolve().is_relative_to(self.guard.root):
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"PATH_OUTSIDE_REPOSITORY", "A build source path resolves outside the repository."
|
||||||
|
)
|
||||||
|
if path.is_symlink() or getattr(path, "is_junction", lambda: False)():
|
||||||
|
if not path.resolve().is_relative_to(self.guard.root):
|
||||||
|
raise LabyricornMcpError(
|
||||||
|
"PATH_OUTSIDE_REPOSITORY", "A linked build source path resolves outside the repository."
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_tests(self) -> dict[str, Any]:
|
||||||
|
return self._run(
|
||||||
|
[sys.executable, "-m", "unittest", "discover", "-s", "tests", "-v"], timeout=600
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_diff(self) -> dict[str, Any]:
|
||||||
|
result = self._run(["git", "diff", "--check", "HEAD"], timeout=60)
|
||||||
|
result["changed_files"] = [entry["path"] for entry in self.git.status()["changes"]]
|
||||||
|
return result
|
||||||
|
|
||||||
|
def validate_changes(self) -> dict[str, Any]:
|
||||||
|
build = self.build_site()
|
||||||
|
tests = self.run_tests()
|
||||||
|
diff = self.check_diff()
|
||||||
|
return {
|
||||||
|
"passed": build["passed"] and tests["passed"] and diff["passed"],
|
||||||
|
"build": build,
|
||||||
|
"tests": tests,
|
||||||
|
"diff_check": diff,
|
||||||
|
"warnings": [
|
||||||
|
line for result in (build, tests, diff) for line in result.get("stderr", "").splitlines()
|
||||||
|
if "warning" in line.casefold()
|
||||||
|
],
|
||||||
|
"changed_files": diff["changed_files"],
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Launch the repository-local Labyricorn MCP package over stdio."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
SITE_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(SITE_ROOT))
|
||||||
|
|
||||||
|
from labyricorn_mcp.__main__ import main # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from io import StringIO
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from labyricorn_mcp.domain import LabyricornDomain
|
||||||
|
from labyricorn_mcp.errors import LabyricornMcpError
|
||||||
|
from labyricorn_mcp.gitops import GitOperations
|
||||||
|
from labyricorn_mcp.safety import RepositoryGuard
|
||||||
|
from labyricorn_mcp.server import StdioMcpServer
|
||||||
|
from labyricorn_mcp.tools import ToolRegistry
|
||||||
|
from labyricorn_mcp.validation import ValidationOperations
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class McpRepositoryFixture(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.base = ROOT / ".cache" / f"mcp-test-{uuid.uuid4().hex}"
|
||||||
|
self.site = self.base / "site"
|
||||||
|
for section, model in (("articles", "section"), ("blog", "section"), ("projects", "section")):
|
||||||
|
directory = self.site / "content" / section
|
||||||
|
directory.mkdir(parents=True)
|
||||||
|
(directory / "contents.lr").write_text(
|
||||||
|
f"_model: {model}\n---\ntitle: {section.title()}\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
tags = self.site / "content" / "tags"
|
||||||
|
(tags / "writing").mkdir(parents=True)
|
||||||
|
(tags / "contents.lr").write_text("_model: tag-index\n---\ntitle: Tags\n", encoding="utf-8")
|
||||||
|
(tags / "writing" / "contents.lr").write_text(
|
||||||
|
"_model: tag\n---\ntitle: Writing\n---\nsummary: Writing and editing.\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(self.site / "models").mkdir()
|
||||||
|
for name in ("entry.ini", "tag.ini", "project.ini", "devlog.ini", "devlog-entry.ini", "section.ini", "tag-index.ini"):
|
||||||
|
shutil.copy2(ROOT / "models" / name, self.site / "models" / name)
|
||||||
|
(self.site / "configs").mkdir()
|
||||||
|
(self.site / "configs" / "project-sources.ini").write_text(
|
||||||
|
"[example]\nrepository = https://example.test/example.git\nweb_url = https://example.test/example\napi_url = https://example.test/api/example\nbranch = main\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(self.site / "scripts").mkdir()
|
||||||
|
for name in (
|
||||||
|
"article_editor.py",
|
||||||
|
"blog_editor.py",
|
||||||
|
"git_repo.py",
|
||||||
|
"narration_import.py",
|
||||||
|
"project_editor.py",
|
||||||
|
"project_providers.py",
|
||||||
|
"tag_editor.py",
|
||||||
|
):
|
||||||
|
shutil.copy2(ROOT / "scripts" / name, self.site / "scripts" / name)
|
||||||
|
(self.site / "scripts" / "build_with_projects.py").write_text("raise SystemExit(0)\n", encoding="utf-8")
|
||||||
|
(self.site / "templates").mkdir()
|
||||||
|
(self.site / "templates" / "base.html").write_text("<main>Test</main>\n", encoding="utf-8")
|
||||||
|
(self.site / "assets" / "static").mkdir(parents=True)
|
||||||
|
(self.site / "assets" / "static" / "style.css").write_text("body {}\n", encoding="utf-8")
|
||||||
|
(self.site / "Labyricorn.lektorproject").write_text(
|
||||||
|
"[project]\nname = Fixture\nurl = https://example.test\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
self.git("init", "--initial-branch=main")
|
||||||
|
self.git("config", "user.name", "MCP Test")
|
||||||
|
self.git("config", "user.email", "[email protected]")
|
||||||
|
self.git("add", ".")
|
||||||
|
self.git("commit", "-m", "Initial fixture")
|
||||||
|
self.guard = RepositoryGuard(self.site)
|
||||||
|
self.domain = LabyricornDomain(self.guard)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
shutil.rmtree(self.base, onexc=self._make_writable)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_writable(function: object, path: str, _error: BaseException) -> None:
|
||||||
|
os.chmod(path, 0o700)
|
||||||
|
function(path)
|
||||||
|
|
||||||
|
def git(self, *args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", *args], cwd=self.site, capture_output=True, text=True, encoding="utf-8", check=False
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
raise AssertionError(result.stderr or result.stdout)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def article_fields(**changes: object) -> dict[str, object]:
|
||||||
|
fields: dict[str, object] = {
|
||||||
|
"title": "MCP Test Article",
|
||||||
|
"date": "2026-08-23",
|
||||||
|
"author": "Test",
|
||||||
|
"tags": ["writing", "Local Experiment"],
|
||||||
|
"summary": "A test article.",
|
||||||
|
"body": "Test body.",
|
||||||
|
}
|
||||||
|
fields.update(changes)
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
class RepositorySafetyTests(McpRepositoryFixture):
|
||||||
|
def test_repository_detection_rejects_wrong_directory(self) -> None:
|
||||||
|
wrong = self.base / "wrong"
|
||||||
|
wrong.mkdir()
|
||||||
|
with self.assertRaisesRegex(LabyricornMcpError, "compatible Labyricorn"):
|
||||||
|
RepositoryGuard(wrong)
|
||||||
|
|
||||||
|
def test_path_traversal_and_absolute_paths_are_rejected(self) -> None:
|
||||||
|
for value in ("../outside.txt", str((self.base / "outside.txt").resolve())):
|
||||||
|
with self.subTest(value=value), self.assertRaisesRegex(LabyricornMcpError, "repository-relative"):
|
||||||
|
self.guard.resolve(value)
|
||||||
|
|
||||||
|
def test_symlink_escape_is_rejected(self) -> None:
|
||||||
|
outside = self.base / "outside.txt"
|
||||||
|
outside.write_text("outside", encoding="utf-8")
|
||||||
|
link = self.site / "templates" / "escape.html"
|
||||||
|
try:
|
||||||
|
link.symlink_to(outside)
|
||||||
|
except OSError as exc:
|
||||||
|
self.skipTest(f"symlink creation is unavailable: {exc}")
|
||||||
|
with self.assertRaisesRegex(LabyricornMcpError, "outside the repository"):
|
||||||
|
self.guard.resolve("templates/escape.html", allowed_roots=("templates",), must_exist=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentDomainTests(McpRepositoryFixture):
|
||||||
|
def test_content_create_read_update_and_conflict(self) -> None:
|
||||||
|
created = self.domain.create_article("mcp-test", self.article_fields())
|
||||||
|
self.assertEqual("writing, local-experiment", created["fields"]["tags"])
|
||||||
|
loaded = self.domain.get_content("article", "mcp-test")
|
||||||
|
updated = self.domain.update_article(
|
||||||
|
"mcp-test", {"summary": "Updated safely."}, loaded["sha256"]
|
||||||
|
)
|
||||||
|
self.assertEqual("Updated safely.", updated["fields"]["summary"])
|
||||||
|
with self.assertRaisesRegex(LabyricornMcpError, "changed after it was read"):
|
||||||
|
self.domain.update_article("mcp-test", {"summary": "Stale"}, loaded["sha256"])
|
||||||
|
|
||||||
|
def test_malformed_content_is_rejected(self) -> None:
|
||||||
|
created = self.domain.create_article("mcp-test", self.article_fields())
|
||||||
|
with (self.site / created["path"]).open("a", encoding="utf-8") as output:
|
||||||
|
output.write("---\ntitle: Duplicate\n")
|
||||||
|
with self.assertRaises(LabyricornMcpError):
|
||||||
|
self.domain.get_content("article", "mcp-test")
|
||||||
|
|
||||||
|
def test_model_description_is_derived_and_invalid_model_rejected(self) -> None:
|
||||||
|
model = self.domain.describe_model("entry")
|
||||||
|
self.assertIn("title", {field["name"] for field in model["fields"]})
|
||||||
|
self.assertEqual("models/entry.ini", model["source"])
|
||||||
|
with self.assertRaisesRegex(LabyricornMcpError, "Model names"):
|
||||||
|
self.domain.describe_model("../entry")
|
||||||
|
|
||||||
|
def test_design_update_requires_matching_hash(self) -> None:
|
||||||
|
loaded = self.domain.get_design_file("templates/base.html")
|
||||||
|
updated = self.domain.update_design_file(
|
||||||
|
"templates/base.html", "<main>Updated</main>\n", loaded["sha256"]
|
||||||
|
)
|
||||||
|
self.assertNotEqual(loaded["sha256"], updated["sha256"])
|
||||||
|
with self.assertRaisesRegex(LabyricornMcpError, "changed after it was read"):
|
||||||
|
self.domain.update_design_file("templates/base.html", "stale", loaded["sha256"])
|
||||||
|
|
||||||
|
|
||||||
|
class GitSafetyTests(McpRepositoryFixture):
|
||||||
|
def test_dirty_state_hash_detects_intervening_change(self) -> None:
|
||||||
|
operations = GitOperations(self.guard)
|
||||||
|
first = self.site / "first.txt"
|
||||||
|
first.write_text("first\n", encoding="utf-8")
|
||||||
|
status = operations.status()
|
||||||
|
(self.site / "second.txt").write_text("second\n", encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(LabyricornMcpError, "changed after it was reviewed"):
|
||||||
|
operations.commit("Test", ["first.txt"], status["status_sha256"])
|
||||||
|
|
||||||
|
def test_commit_is_limited_to_reviewed_paths(self) -> None:
|
||||||
|
operations = GitOperations(self.guard)
|
||||||
|
(self.site / "new").mkdir()
|
||||||
|
(self.site / "new" / "first.txt").write_text("first\n", encoding="utf-8")
|
||||||
|
(self.site / "second.txt").write_text("second\n", encoding="utf-8")
|
||||||
|
status = operations.status()
|
||||||
|
result = operations.commit("Commit one file", ["new/first.txt"], status["status_sha256"])
|
||||||
|
self.assertEqual(["new/first.txt"], result["paths"])
|
||||||
|
self.assertIn("second.txt", {item["path"] for item in result["status"]["changes"]})
|
||||||
|
self.assertEqual("Commit one file", self.git("log", "-1", "--format=%s").stdout.strip())
|
||||||
|
|
||||||
|
def test_sensitive_file_cannot_be_committed(self) -> None:
|
||||||
|
operations = GitOperations(self.guard)
|
||||||
|
(self.site / ".env").write_text("TOKEN=secret\n", encoding="utf-8")
|
||||||
|
status = operations.status()
|
||||||
|
with self.assertRaisesRegex(LabyricornMcpError, "not eligible"):
|
||||||
|
operations.commit("Do not commit", [".env"], status["status_sha256"])
|
||||||
|
|
||||||
|
|
||||||
|
class ProtocolAndValidationTests(McpRepositoryFixture):
|
||||||
|
def test_tool_registration_has_focused_surface_and_no_shell_or_deploy(self) -> None:
|
||||||
|
names = {tool["name"] for tool in ToolRegistry(self.guard).tools}
|
||||||
|
self.assertTrue({"site_info", "create_article", "validate_changes", "push_changes"} <= names)
|
||||||
|
self.assertFalse({"shell", "exec", "deploy", "publish_site"} & names)
|
||||||
|
|
||||||
|
def test_mutation_reports_unrelated_worktree_context(self) -> None:
|
||||||
|
(self.site / "unrelated.txt").write_text("keep me\n", encoding="utf-8")
|
||||||
|
result = ToolRegistry(self.guard).call(
|
||||||
|
"create_article", {"slug": "mcp-test", "fields": self.article_fields()}
|
||||||
|
)
|
||||||
|
context = result["worktree_context"]
|
||||||
|
self.assertIn("unrelated.txt", context["before_changed_paths"])
|
||||||
|
self.assertIn("content/articles/mcp-test/", context["after_changed_paths"])
|
||||||
|
|
||||||
|
def test_stdio_protocol_initializes_lists_and_calls_tools(self) -> None:
|
||||||
|
messages = [
|
||||||
|
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "1"}}},
|
||||||
|
{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||||
|
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
|
||||||
|
{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "site_structure", "arguments": {}}},
|
||||||
|
]
|
||||||
|
input_stream = StringIO("".join(json.dumps(message) + "\n" for message in messages))
|
||||||
|
output_stream = StringIO()
|
||||||
|
server = StdioMcpServer(self.guard, input_stream=input_stream, output_stream=output_stream)
|
||||||
|
self.assertEqual(0, server.serve_forever())
|
||||||
|
responses = [json.loads(line) for line in output_stream.getvalue().splitlines()]
|
||||||
|
self.assertEqual([1, 2, 3], [response["id"] for response in responses])
|
||||||
|
self.assertEqual("2025-06-18", responses[0]["result"]["protocolVersion"])
|
||||||
|
self.assertGreaterEqual(len(responses[1]["result"]["tools"]), 30)
|
||||||
|
self.assertFalse(responses[2]["result"]["isError"])
|
||||||
|
|
||||||
|
def test_validation_failure_is_structured(self) -> None:
|
||||||
|
git = GitOperations(self.guard)
|
||||||
|
validation = ValidationOperations(self.guard, git)
|
||||||
|
validation._lektor_executable = lambda: "lektor" # type: ignore[method-assign]
|
||||||
|
validation._run = lambda _command, timeout: { # type: ignore[method-assign]
|
||||||
|
"passed": False, "returncode": 1, "stdout": "", "stderr": "build failed", "duration_seconds": 0.1
|
||||||
|
}
|
||||||
|
result = validation.build_site()
|
||||||
|
self.assertFalse(result["passed"])
|
||||||
|
self.assertEqual("build failed", result["stderr"])
|
||||||
|
self.assertFalse(result["output_retained"])
|
||||||
|
|
||||||
|
|
||||||
|
class RealStdioStartupTests(unittest.TestCase):
|
||||||
|
def test_module_starts_over_stdio_without_protocol_noise(self) -> None:
|
||||||
|
request = json.dumps(
|
||||||
|
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "1"}}}
|
||||||
|
) + "\n"
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "labyricorn_mcp", "--repository", str(ROOT)],
|
||||||
|
cwd=ROOT,
|
||||||
|
input=request,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
timeout=30,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
lines = result.stdout.splitlines()
|
||||||
|
self.assertEqual(1, len(lines))
|
||||||
|
response = json.loads(lines[0])
|
||||||
|
self.assertEqual("labyricorn-mcp", response["result"]["serverInfo"]["name"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user