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

220 lines
15 KiB
Python

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