Files
ITWorx_MCP_Hub-Public/services/runtime/mcp_skills.py
T

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