"""Static, digest-verified MCP Skills extension for the Gitea plugin.""" from __future__ import annotations import hashlib import json import os from pathlib import Path, PurePosixPath from typing import Any import yaml # type: ignore[import-untyped] from mcp import types as mcp_types from mcp.server.apps import Apps, ResourceCsp from mcp.server.extension import Extension, MethodBinding, ResourceBinding from mcp.server.mcpserver.resources.types import TextResource from pydantic import Field from .gitea_operations_widget import OPERATIONS_WIDGET_HTML, OPERATIONS_WIDGET_URI from .gitea_review_widget import CHANGE_REVIEW_WIDGET_HTML, CHANGE_REVIEW_WIDGET_URI _EXTENSION_ID = "io.modelcontextprotocol/skills" _SERVER_SLUG = "itworx-gitea" _MAX_SKILLS = 5 _MAX_FILES_PER_SKILL = 100 _MAX_SKILL_MD_BYTES = 256 * 1024 _MAX_SUPPORTING_FILE_BYTES = 1024 * 1024 _MAX_SKILL_BYTES = 5 * 1024 * 1024 _LEGACY_WIDGETS = ( ( "ui://itworx-gitea/change-review-v3.html", CHANGE_REVIEW_WIDGET_HTML, "ITWorx Gitea change review (v3)", "Repository change review", "Backward-compatible repository change review surface.", ), ( "ui://itworx-gitea/change-review-v2.html", CHANGE_REVIEW_WIDGET_HTML, "ITWorx Gitea change review (legacy)", "Repository change review", "Backward-compatible repository change review surface.", ), ( "ui://itworx-gitea/operations-v3.html", OPERATIONS_WIDGET_HTML, "ITWorx Gitea autonomous workspace (v3)", "Repository workspace operations", "Backward-compatible repository workspace operations surface.", ), ( "ui://itworx-gitea/operations-v2.html", OPERATIONS_WIDGET_HTML, "ITWorx Gitea autonomous workspace (legacy)", "Repository workspace operations", "Backward-compatible repository workspace operations surface.", ), ) class SkillListParams(mcp_types.RequestParams): cursor: str | None = Field(default=None, max_length=100) class SkillGetParams(mcp_types.RequestParams): uri: str = Field(min_length=1, max_length=2048) def _default_skills_root() -> Path: return Path(__file__).resolve().parents[2] / "plugins" / "itworx-gitea" / "skills" def _frontmatter(text: str, *, source: Path) -> dict[str, Any]: lines = text.splitlines() if not lines or lines[0].strip() != "---": raise ValueError(f"MCP skill front matter is missing: {source.name}") try: closing = next(index for index, line in enumerate(lines[1:], 1) if line.strip() == "---") except StopIteration as exc: raise ValueError(f"MCP skill front matter is unterminated: {source.name}") from exc parsed = yaml.safe_load("\n".join(lines[1:closing])) if not isinstance(parsed, dict) or not all(isinstance(key, str) for key in parsed): raise ValueError(f"MCP skill front matter is invalid: {source.name}") if not isinstance(parsed.get("name"), str) or not isinstance(parsed.get("description"), str): raise ValueError(f"MCP skill front matter needs name and description: {source.name}") return parsed def _mime_type(relative: PurePosixPath) -> str: suffix = relative.suffix.casefold() if suffix == ".md": return "text/markdown" if suffix in {".yaml", ".yml"}: return "application/yaml" if suffix == ".json": return "application/json" return "text/plain" def _application_params(params: dict[str, Any]) -> dict[str, Any]: """Return method parameters after validating the standard MCP metadata field.""" metadata = params.get("_meta") if metadata is not None and not isinstance(metadata, dict): raise ValueError("invalid MCP request metadata") return {key: value for key, value in params.items() if key != "_meta"} class StaticSkills(Extension): """Serve a bounded snapshot of repository-engineering skills.""" identifier = _EXTENSION_ID def __init__(self, root: Path | None = None, *, serve_public_widgets: bool = False) -> None: configured = os.environ.get("GITEA_SKILLS_ROOT", "").strip() self.root = (root or (Path(configured) if configured else _default_skills_root())).resolve() self.serve_public_widgets = serve_public_widgets self._skills: tuple[dict[str, Any], ...] self._resources: tuple[ResourceBinding, ...] self._skills, self._resources = self._load() apps = Apps() if serve_public_widgets: apps.add_html_resource( CHANGE_REVIEW_WIDGET_URI, CHANGE_REVIEW_WIDGET_HTML, name="ITWorx Gitea change review", title="Repository change review", description=( "Interactive review surface for a safe branch, PR, diff and validations." ), csp=ResourceCsp(connect_domains=[], resource_domains=[]), prefers_border=True, ) apps.add_html_resource( OPERATIONS_WIDGET_URI, OPERATIONS_WIDGET_HTML, name="ITWorx Gitea autonomous workspace", title="Repository workspace operations", description=( "Interactive capability, connection, workspace and portfolio status surface." ), csp=ResourceCsp(connect_domains=[], resource_domains=[]), prefers_border=True, ) # ChatGPT stores the output-template URI in existing messages. Keep # the previous revision readable so reconnects and retries do not # strand an otherwise valid tool result after a template release. for uri, html, name, title, description in _LEGACY_WIDGETS: apps.add_html_resource( uri, html, name=name, title=title, description=description, csp=ResourceCsp(connect_domains=[], resource_domains=[]), prefers_border=True, ) self._public_widgets = tuple(binding.resource for binding in apps.resources()) @property def catalog(self) -> tuple[dict[str, Any], ...]: return self._skills def _load(self) -> tuple[tuple[dict[str, Any], ...], tuple[ResourceBinding, ...]]: if not self.root.is_dir(): raise ValueError("Gitea MCP skills directory is unavailable") skill_entries: list[dict[str, Any]] = [] resource_bindings: list[ResourceBinding] = [] seen_names: set[str] = set() for skill_dir in sorted(path for path in self.root.iterdir() if path.is_dir()): skill_md = skill_dir / "SKILL.md" if not skill_md.is_file() or skill_md.is_symlink(): continue files = sorted(path for path in skill_dir.rglob("*") if path.is_file()) files.sort(key=lambda path: (path != skill_md, path.relative_to(skill_dir).as_posix())) if not 1 <= len(files) <= _MAX_FILES_PER_SKILL: raise ValueError("MCP skill file count is outside the supported limit") resources: list[dict[str, str]] = [] total_bytes = 0 parsed_frontmatter: dict[str, Any] | None = None for path in files: resolved = path.resolve() if path.is_symlink() or not resolved.is_relative_to(skill_dir.resolve()): raise ValueError("MCP skill resource escapes its skill directory") raw = path.read_bytes() relative = PurePosixPath(path.relative_to(skill_dir).as_posix()) maximum = ( _MAX_SKILL_MD_BYTES if relative.as_posix() == "SKILL.md" else _MAX_SUPPORTING_FILE_BYTES ) if len(raw) > maximum: raise ValueError("MCP skill resource exceeds the supported size limit") total_bytes += len(raw) try: text = raw.decode("utf-8") except UnicodeDecodeError as exc: raise ValueError("MCP skill resources must be UTF-8 text") from exc if relative.as_posix() == "SKILL.md": parsed_frontmatter = _frontmatter(text, source=path) if parsed_frontmatter["name"] != skill_dir.name: raise ValueError("MCP skill directory must match its front matter name") uri = f"skill://{_SERVER_SLUG}/{skill_dir.name}/{relative.as_posix()}" digest = f"sha256:{hashlib.sha256(raw).hexdigest()}" resources.append({"uri": uri, "digest": digest}) resource_bindings.append( ResourceBinding( TextResource( uri=uri, name=f"{skill_dir.name}/{relative.as_posix()}", mime_type=_mime_type(relative), text=text, ) ) ) if total_bytes > _MAX_SKILL_BYTES: raise ValueError("MCP skill exceeds the supported aggregate size limit") if parsed_frontmatter is None: raise ValueError("MCP skill SKILL.md was not loaded") name = str(parsed_frontmatter["name"]) if name in seen_names: raise ValueError("MCP skill names must be unique") seen_names.add(name) skill_entries.append( { "uri": f"skill://{_SERVER_SLUG}/{name}/SKILL.md", "frontmatter": parsed_frontmatter, "resources": resources, } ) if not 1 <= len(skill_entries) <= _MAX_SKILLS: raise ValueError("MCP skill count is outside the supported limit") return tuple(skill_entries), tuple(resource_bindings) async def _list(self, _context: Any, params: SkillListParams) -> dict[str, Any]: if params.cursor is not None: raise ValueError("MCP skills catalog has no additional page") return {"skills": list(self._skills)} async def _get(self, _context: Any, params: SkillGetParams) -> dict[str, Any]: skill = next((item for item in self._skills if item["uri"] == params.uri), None) if skill is None: raise ValueError("MCP skill URI is not registered") return {"skill": skill} def resources(self) -> tuple[ResourceBinding, ...]: return self._resources def methods(self) -> tuple[MethodBinding, ...]: return ( MethodBinding("skills/list", SkillListParams, self._list), MethodBinding("skills/get", SkillGetParams, self._get), ) def handle_public_method( self, project_slug: str, method: str, params: dict[str, Any] ) -> dict[str, Any] | None: """Serve bounded resources that ContextForge 1.0.6 cannot aggregate safely.""" if project_slug != "gitea-all": return None application_params = _application_params(params) if method == "resources/list" and self.serve_public_widgets: if set(application_params) - {"cursor"} or application_params.get("cursor") is not None: raise ValueError("invalid resources/list parameters") return { "resources": [ { "uri": str(resource.uri), "name": resource.name, "title": resource.title, "description": resource.description, "mimeType": resource.mime_type, "_meta": resource.meta, } for resource in self._public_widgets ] } if method == "skills/list": if set(application_params) - {"cursor"} or application_params.get("cursor") is not None: raise ValueError("invalid skills/list parameters") return {"skills": list(self._skills)} if method == "skills/get": if set(application_params) != {"uri"} or not isinstance( application_params.get("uri"), str ): raise ValueError("invalid skills/get parameters") skill = next( (item for item in self._skills if item["uri"] == application_params["uri"]), None, ) if skill is None: raise ValueError("skill URI is not registered") return {"skill": skill} if method == "resources/read" and ( str(application_params.get("uri", "")).startswith("skill://") or ( self.serve_public_widgets and str(application_params.get("uri", "")).startswith("ui://itworx-gitea/") ) ): if set(application_params) != {"uri"} or not isinstance( application_params.get("uri"), str ): raise ValueError("invalid resources/read parameters") resource = next( ( binding.resource for binding in self._resources if str(binding.resource.uri) == application_params["uri"] ), None, ) if resource is None: resource = next( ( candidate for candidate in self._public_widgets if str(candidate.uri) == application_params["uri"] ), None, ) if resource is None or not isinstance(resource, TextResource): raise ValueError("public resource URI is not registered") content: dict[str, Any] = { "uri": str(resource.uri), "mimeType": resource.mime_type, "text": resource.text, } if resource.meta: content["_meta"] = resource.meta return {"contents": [content]} return None def augment_initialize(self, project_slug: str, content: bytes, content_type: str) -> bytes: """Advertise Skills on the public aggregate response without changing base capabilities.""" if project_slug != "gitea-all": return content def augment(document: Any) -> Any: if not isinstance(document, dict) or not isinstance(document.get("result"), dict): return document capabilities = document["result"].setdefault("capabilities", {}) if not isinstance(capabilities, dict): return document extensions = capabilities.setdefault("extensions", {}) if isinstance(extensions, dict): extensions.setdefault(_EXTENSION_ID, {}) if self.serve_public_widgets: resources = capabilities.setdefault("resources", {}) if isinstance(resources, dict): resources.setdefault("listChanged", False) return document try: if "text/event-stream" not in content_type.casefold(): return json.dumps(augment(json.loads(content))).encode() output: list[bytes] = [] for line in content.splitlines(keepends=True): if line.startswith(b"data:"): prefix, value = line.split(b":", 1) ending = b"\r\n" if line.endswith(b"\r\n") else b"\n" payload = value.strip() line = ( prefix + b": " + json.dumps(augment(json.loads(payload))).encode() + ending ) output.append(line) return b"".join(output) except (UnicodeDecodeError, json.JSONDecodeError, ValueError, TypeError): return content