Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,367 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user