"""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)