220 lines
15 KiB
Python
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)
|