368 lines
16 KiB
Python
368 lines
16 KiB
Python
"""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
|