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,4 @@
|
||||
# Connectors
|
||||
|
||||
Reserved for target-specific, read-only connector implementations. Connectors
|
||||
must not import peer connectors or access the Hub database directly.
|
||||
@@ -0,0 +1 @@
|
||||
"""First-party connector implementations."""
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Gitea read-only connector package."""
|
||||
|
||||
from .changesets import DulwichChangesetWorkspace
|
||||
from .fake import FakeGiteaServer
|
||||
from .server import GITEA_RESOURCES, GITEA_TOOLS, GiteaConnector
|
||||
|
||||
__all__ = [
|
||||
"DulwichChangesetWorkspace",
|
||||
"FakeGiteaServer",
|
||||
"GITEA_RESOURCES",
|
||||
"GITEA_TOOLS",
|
||||
"GiteaConnector",
|
||||
]
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Bounded patching and single-commit Git workspaces for Gitea changesets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any, Protocol
|
||||
|
||||
from dulwich import porcelain
|
||||
from dulwich.repo import Repo
|
||||
|
||||
from packages.connector_kit import GiteaError, RegisteredRepository
|
||||
from packages.connector_kit.gitea import TokenProvider
|
||||
|
||||
_HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$")
|
||||
_SHA = re.compile(r"^[0-9a-fA-F]{40,64}$")
|
||||
_MAX_CHANGESET_BYTES = 1_000_000
|
||||
_MAX_OPERATIONS = 50
|
||||
_MAX_WORKSPACE_BYTES = 256 * 1024 * 1024
|
||||
_DULWICH_IDENTITY_LOCK = Lock()
|
||||
_DULWICH_IDENTITY = {"USER": "mcp-agent", "EMAIL": "mcp-agent@example.com"}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _dulwich_clone_identity() -> Iterator[None]:
|
||||
"""Give Dulwich a bounded reflog identity without mutating process state concurrently."""
|
||||
|
||||
with _DULWICH_IDENTITY_LOCK:
|
||||
previous = {name: os.environ.get(name) for name in _DULWICH_IDENTITY}
|
||||
os.environ.update(_DULWICH_IDENTITY)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for name, value in previous.items():
|
||||
if value is None:
|
||||
os.environ.pop(name, None)
|
||||
else:
|
||||
os.environ[name] = value
|
||||
|
||||
|
||||
def _hex_object_id(value: bytes) -> str:
|
||||
try:
|
||||
decoded = value.decode("ascii")
|
||||
except UnicodeDecodeError:
|
||||
return value.hex()
|
||||
return decoded.casefold() if _SHA.fullmatch(decoded) else value.hex()
|
||||
|
||||
|
||||
class ChangesetWorkspace(Protocol):
|
||||
def apply(
|
||||
self,
|
||||
repository: RegisteredRepository,
|
||||
branch: str,
|
||||
message: str,
|
||||
operations: Sequence[Mapping[str, Any]],
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
def apply_unified_patch(original: str, patch: str) -> str:
|
||||
"""Apply a single-file unified diff with exact context matching."""
|
||||
if not patch or len(patch.encode("utf-8")) > 300_000 or "\x00" in patch:
|
||||
raise GiteaError("GITEA_PATCH_INVALID", "patch is outside the connector budget")
|
||||
lines = patch.splitlines(keepends=True)
|
||||
index = 0
|
||||
while index < len(lines) and not lines[index].startswith("@@ "):
|
||||
if lines[index].startswith(("GIT binary patch", "Binary files ")):
|
||||
raise GiteaError("GITEA_PATCH_INVALID", "binary patches are not supported")
|
||||
index += 1
|
||||
if index == len(lines):
|
||||
raise GiteaError("GITEA_PATCH_INVALID", "patch contains no unified diff hunk")
|
||||
|
||||
source = original.splitlines(keepends=True)
|
||||
output: list[str] = []
|
||||
source_index = 0
|
||||
seen_hunk = False
|
||||
while index < len(lines):
|
||||
header = lines[index].rstrip("\r\n")
|
||||
match = _HUNK.fullmatch(header)
|
||||
if match is None:
|
||||
raise GiteaError("GITEA_PATCH_INVALID", "patch contains an invalid hunk header")
|
||||
seen_hunk = True
|
||||
old_start = int(match.group(1))
|
||||
old_count = int(match.group(2) or "1")
|
||||
new_count = int(match.group(4) or "1")
|
||||
target_index = max(old_start - 1, 0)
|
||||
if target_index < source_index or target_index > len(source):
|
||||
raise GiteaError("GITEA_PATCH_CONFLICT", "patch hunks overlap or exceed the file")
|
||||
output.extend(source[source_index:target_index])
|
||||
source_index = target_index
|
||||
index += 1
|
||||
consumed = 0
|
||||
produced = 0
|
||||
while index < len(lines) and not lines[index].startswith("@@ "):
|
||||
line = lines[index]
|
||||
if line.startswith("\\ No newline at end of file"):
|
||||
index += 1
|
||||
continue
|
||||
if not line or line[0] not in {" ", "+", "-"}:
|
||||
raise GiteaError("GITEA_PATCH_INVALID", "patch contains an invalid hunk line")
|
||||
marker, value = line[0], line[1:]
|
||||
if marker in {" ", "-"}:
|
||||
if source_index >= len(source) or source[source_index] != value:
|
||||
raise GiteaError("GITEA_PATCH_CONFLICT", "patch context does not match")
|
||||
source_index += 1
|
||||
consumed += 1
|
||||
if marker in {" ", "+"}:
|
||||
output.append(value)
|
||||
produced += 1
|
||||
index += 1
|
||||
if consumed != old_count or produced != new_count:
|
||||
raise GiteaError("GITEA_PATCH_INVALID", "patch hunk counts do not match")
|
||||
if not seen_hunk:
|
||||
raise GiteaError("GITEA_PATCH_INVALID", "patch contains no hunks")
|
||||
output.extend(source[source_index:])
|
||||
result = "".join(output)
|
||||
if len(result.encode("utf-8")) > 200_000:
|
||||
raise GiteaError("GITEA_INPUT_INVALID", "patched file exceeds the connector budget")
|
||||
return result
|
||||
|
||||
|
||||
def validate_operations(operations: Sequence[Mapping[str, Any]]) -> tuple[dict[str, Any], ...]:
|
||||
if not 1 <= len(operations) <= _MAX_OPERATIONS:
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset operation count is invalid")
|
||||
total = 0
|
||||
normalized: list[dict[str, Any]] = []
|
||||
touched: set[str] = set()
|
||||
for raw in operations:
|
||||
kind = str(raw.get("kind", ""))
|
||||
if kind not in {"create", "update", "delete", "move", "patch"}:
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset operation kind is invalid")
|
||||
path = str(raw.get("path", ""))
|
||||
if not path or path in touched:
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset paths must be unique")
|
||||
touched.add(path)
|
||||
operation: dict[str, Any] = {"kind": kind, "path": path}
|
||||
for field in ("content", "patch", "expectedSha", "destinationPath"):
|
||||
if field in raw and raw[field] is not None:
|
||||
operation[field] = str(raw[field])
|
||||
if kind in {"update", "delete", "move", "patch"} and not _SHA.fullmatch(
|
||||
operation.get("expectedSha", "")
|
||||
):
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "expectedSha is required and invalid")
|
||||
if kind in {"create", "update"} and "content" not in operation:
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "content is required")
|
||||
if kind == "patch" and "patch" not in operation:
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "patch text is required")
|
||||
if kind == "move" and not operation.get("destinationPath"):
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "destinationPath is required")
|
||||
destination = operation.get("destinationPath")
|
||||
if destination is not None:
|
||||
if destination in touched:
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset paths must be unique")
|
||||
touched.add(destination)
|
||||
total += sum(len(str(value).encode("utf-8")) for value in operation.values())
|
||||
normalized.append(operation)
|
||||
if total > _MAX_CHANGESET_BYTES:
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset exceeds the connector budget")
|
||||
return tuple(normalized)
|
||||
|
||||
|
||||
class DulwichChangesetWorkspace:
|
||||
"""Apply an already-validated changeset through a fixed, isolated Git workflow."""
|
||||
|
||||
def __init__(self, base_url: str, token_provider: TokenProvider, token_reference: str) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._token_provider = token_provider
|
||||
self._token_reference = token_reference
|
||||
|
||||
@staticmethod
|
||||
def _relative(root: Path, value: str) -> Path:
|
||||
if not value or value.startswith(("/", "\\")) or "\\" in value:
|
||||
raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid")
|
||||
parts = value.split("/")
|
||||
if any(not part or part in {".", ".."} or "\x00" in part for part in parts):
|
||||
raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid")
|
||||
target = root.joinpath(*parts)
|
||||
try:
|
||||
target.resolve(strict=False).relative_to(root.resolve())
|
||||
except ValueError as exc:
|
||||
raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid") from exc
|
||||
return target
|
||||
|
||||
@staticmethod
|
||||
def _blob_sha(repo: Repo, relative: str) -> str | None:
|
||||
try:
|
||||
entry = repo.open_index()[relative.encode("utf-8")]
|
||||
except KeyError:
|
||||
return None
|
||||
sha = getattr(entry, "sha", None)
|
||||
return _hex_object_id(sha) if sha is not None else None
|
||||
|
||||
def apply(
|
||||
self,
|
||||
repository: RegisteredRepository,
|
||||
branch: str,
|
||||
message: str,
|
||||
operations: Sequence[Mapping[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
normalized = validate_operations(operations)
|
||||
token = self._token_provider.get_token(self._token_reference)
|
||||
remote = f"{self._base_url}/{repository.owner}/{repository.name}.git"
|
||||
authenticated = remote.startswith(("https://", "http://"))
|
||||
stderr = io.BytesIO()
|
||||
with tempfile.TemporaryDirectory(prefix="itworx-gitea-") as temporary:
|
||||
root = Path(temporary) / "repository"
|
||||
repo: Repo | None = None
|
||||
try:
|
||||
with _dulwich_clone_identity():
|
||||
if authenticated:
|
||||
repo = porcelain.clone(
|
||||
remote,
|
||||
root,
|
||||
branch=branch,
|
||||
depth=1,
|
||||
errstream=stderr,
|
||||
username="oauth2",
|
||||
password=token,
|
||||
)
|
||||
else:
|
||||
repo = porcelain.clone(
|
||||
remote, root, branch=branch, depth=1, errstream=stderr
|
||||
)
|
||||
workspace_bytes = sum(
|
||||
entry.stat(follow_symlinks=False).st_size
|
||||
for entry in root.rglob("*")
|
||||
if entry.is_file() and not entry.is_symlink()
|
||||
)
|
||||
if workspace_bytes > _MAX_WORKSPACE_BYTES:
|
||||
raise GiteaError(
|
||||
"GITEA_CHANGESET_TOO_LARGE",
|
||||
"repository exceeds the changeset workspace budget",
|
||||
)
|
||||
staged: list[str] = []
|
||||
removed: list[str] = []
|
||||
for operation in normalized:
|
||||
kind = operation["kind"]
|
||||
relative = operation["path"]
|
||||
target = self._relative(root, relative)
|
||||
actual_sha = self._blob_sha(repo, relative)
|
||||
expected_sha = operation.get("expectedSha")
|
||||
if expected_sha is not None and actual_sha != expected_sha.casefold():
|
||||
raise GiteaError(
|
||||
"GITEA_CHANGESET_CONFLICT", f"stale expectedSha for {relative}"
|
||||
)
|
||||
if kind == "create":
|
||||
if actual_sha is not None or target.exists():
|
||||
raise GiteaError(
|
||||
"GITEA_CHANGESET_CONFLICT", f"file already exists: {relative}"
|
||||
)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(operation["content"], encoding="utf-8")
|
||||
staged.append(relative)
|
||||
elif kind == "update":
|
||||
if actual_sha is None:
|
||||
raise GiteaError(
|
||||
"GITEA_CHANGESET_CONFLICT", f"file does not exist: {relative}"
|
||||
)
|
||||
target.write_text(operation["content"], encoding="utf-8")
|
||||
staged.append(relative)
|
||||
elif kind == "patch":
|
||||
if actual_sha is None:
|
||||
raise GiteaError(
|
||||
"GITEA_CHANGESET_CONFLICT", f"file does not exist: {relative}"
|
||||
)
|
||||
original = target.read_text(encoding="utf-8")
|
||||
target.write_text(
|
||||
apply_unified_patch(original, operation["patch"]), encoding="utf-8"
|
||||
)
|
||||
staged.append(relative)
|
||||
elif kind == "delete":
|
||||
if actual_sha is None:
|
||||
raise GiteaError(
|
||||
"GITEA_CHANGESET_CONFLICT", f"file does not exist: {relative}"
|
||||
)
|
||||
target.unlink()
|
||||
removed.append(relative)
|
||||
else:
|
||||
destination = str(operation["destinationPath"])
|
||||
destination_target = self._relative(root, destination)
|
||||
if actual_sha is None or destination_target.exists():
|
||||
raise GiteaError(
|
||||
"GITEA_CHANGESET_CONFLICT", "move source/destination is invalid"
|
||||
)
|
||||
destination_target.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(target, destination_target)
|
||||
removed.append(relative)
|
||||
staged.append(destination)
|
||||
if removed:
|
||||
porcelain.rm(repo, removed)
|
||||
if staged:
|
||||
porcelain.add(repo, staged)
|
||||
status = porcelain.status(repo)
|
||||
if not status.staged and not status.unstaged and not status.untracked:
|
||||
raise GiteaError("GITEA_CHANGESET_EMPTY", "changeset produces no change")
|
||||
commit_sha = _hex_object_id(
|
||||
porcelain.commit(
|
||||
repo,
|
||||
message=message.encode("utf-8"),
|
||||
author=b"ChatGPT MCP <mcp-agent@example.com>",
|
||||
committer=b"ChatGPT MCP <mcp-agent@example.com>",
|
||||
)
|
||||
)
|
||||
if authenticated:
|
||||
porcelain.push(
|
||||
repo,
|
||||
remote,
|
||||
refspecs=f"refs/heads/{branch}:refs/heads/{branch}",
|
||||
force=False,
|
||||
errstream=stderr,
|
||||
username="oauth2",
|
||||
password=token,
|
||||
)
|
||||
else:
|
||||
porcelain.push(
|
||||
repo,
|
||||
remote,
|
||||
refspecs=f"refs/heads/{branch}:refs/heads/{branch}",
|
||||
force=False,
|
||||
errstream=stderr,
|
||||
)
|
||||
except GiteaError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise GiteaError(
|
||||
"GITEA_CHANGESET_FAILED", "bounded Git changeset could not be committed"
|
||||
) from exc
|
||||
finally:
|
||||
if repo is not None:
|
||||
repo.close()
|
||||
return {
|
||||
"commitSha": commit_sha,
|
||||
"branch": branch,
|
||||
"operationCount": len(normalized),
|
||||
"atomic": True,
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
"""Deterministic in-memory Gitea HTTP transport for contract tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from .managed_validation import (
|
||||
MANAGED_VALIDATION_CONTEXT,
|
||||
MANAGED_VALIDATION_PATH,
|
||||
MANAGED_VALIDATION_WORKFLOW,
|
||||
)
|
||||
|
||||
|
||||
class FakeGiteaServer:
|
||||
"""A tiny bounded Gitea API fixture with observable requests and no network."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unsupported_search: bool = False,
|
||||
deny_user_repositories: bool = False,
|
||||
deny_protected_workflow_writes: bool = False,
|
||||
redirect_to: str | None = None,
|
||||
gitea_version: str = "1.26.4",
|
||||
deny_merge_transition: bool = False,
|
||||
) -> None:
|
||||
self.unsupported_search = unsupported_search
|
||||
self.deny_user_repositories = deny_user_repositories
|
||||
self.deny_protected_workflow_writes = deny_protected_workflow_writes
|
||||
self.redirect_to = redirect_to
|
||||
self.gitea_version = gitea_version
|
||||
self.deny_merge_transition = deny_merge_transition
|
||||
self.recorded_methods: list[str] = []
|
||||
self.recorded_authorizations: list[str] = []
|
||||
self.recorded_paths: list[str] = []
|
||||
self.recorded_bodies: list[dict[str, Any]] = []
|
||||
self._repos = {
|
||||
"acme/hub": {
|
||||
"owner": {"login": "acme"},
|
||||
"name": "hub",
|
||||
"full_name": "acme/hub",
|
||||
"description": "ITWorx MCP Hub",
|
||||
"private": True,
|
||||
"default_branch": "main",
|
||||
"updated_at": "2026-08-01T10:00:00Z",
|
||||
"token": "fixture-token-must-not-leak",
|
||||
}
|
||||
}
|
||||
self._branches: list[dict[str, Any]] = [
|
||||
{"name": "main", "commit": {"id": "a" * 40}, "protected": True},
|
||||
{"name": "release", "commit": {"id": "b" * 40}, "protected": False},
|
||||
{"name": "feature", "commit": {"id": "c" * 40}, "protected": False},
|
||||
]
|
||||
self._branch_protections: dict[str, list[dict[str, Any]]] = {
|
||||
"acme/hub": [
|
||||
{
|
||||
"rule_name": "main",
|
||||
"branch_name": "main",
|
||||
"priority": 1,
|
||||
"enable_push": False,
|
||||
"enable_push_whitelist": False,
|
||||
"push_whitelist_usernames": [],
|
||||
"push_whitelist_teams": [],
|
||||
"push_whitelist_deploy_keys": False,
|
||||
"enable_force_push": False,
|
||||
"enable_force_push_allowlist": False,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"force_push_allowlist_teams": [],
|
||||
"force_push_allowlist_deploy_keys": False,
|
||||
"enable_merge_whitelist": False,
|
||||
"merge_whitelist_usernames": [],
|
||||
"merge_whitelist_teams": [],
|
||||
"enable_bypass_allowlist": False,
|
||||
"bypass_allowlist_usernames": [],
|
||||
"bypass_allowlist_teams": [],
|
||||
"enable_status_check": True,
|
||||
"status_check_contexts": [MANAGED_VALIDATION_CONTEXT],
|
||||
"required_approvals": 0,
|
||||
"enable_approvals_whitelist": False,
|
||||
"approvals_whitelist_username": [],
|
||||
"approvals_whitelist_teams": [],
|
||||
"block_on_rejected_reviews": True,
|
||||
"block_on_official_review_requests": False,
|
||||
"block_on_outdated_branch": True,
|
||||
"dismiss_stale_approvals": True,
|
||||
"ignore_stale_approvals": False,
|
||||
"require_signed_commits": False,
|
||||
"protected_file_patterns": "",
|
||||
"unprotected_file_patterns": "",
|
||||
"block_admin_merge_override": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
self._managed_workflows: dict[str, str] = {"acme/hub": MANAGED_VALIDATION_WORKFLOW}
|
||||
self._commits = [
|
||||
{
|
||||
"sha": "a" * 40,
|
||||
"commit": {"message": "Add bounded connector", "author": {"name": "Ada"}},
|
||||
},
|
||||
{"sha": "b" * 40, "commit": {"message": "Document the API", "author": {"name": "Lin"}}},
|
||||
{"sha": "c" * 40, "commit": {"message": "Add tests", "author": {"name": "Kai"}}},
|
||||
]
|
||||
self._compare_commits = [self._commits[0]]
|
||||
self._reverse_compare_commits: list[dict[str, Any]] = []
|
||||
self._releases = [
|
||||
{"tag_name": "v1.0.0", "name": "First release", "draft": False, "prerelease": False}
|
||||
]
|
||||
self._issues = [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Bounded read API",
|
||||
"state": "open",
|
||||
"html_url": "https://gitea.example/acme/hub/issues/1",
|
||||
}
|
||||
]
|
||||
self._pulls = [
|
||||
{
|
||||
"number": 7,
|
||||
"title": "Add governed Codex workflows",
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
"mergeable": True,
|
||||
"html_url": "https://gitea.example/acme/hub/pulls/7",
|
||||
"head": {"ref": "codex/workflows", "sha": "e" * 40},
|
||||
"base": {"ref": "main", "sha": "a" * 40},
|
||||
"updated_at": "2026-08-21T20:00:00Z",
|
||||
}
|
||||
]
|
||||
self._pull_files = [
|
||||
{
|
||||
"filename": "services/runtime/mcp_server.py",
|
||||
"status": "modified",
|
||||
"additions": 42,
|
||||
"deletions": 2,
|
||||
}
|
||||
]
|
||||
self._statuses = [
|
||||
{
|
||||
"status": "success",
|
||||
"context": MANAGED_VALIDATION_CONTEXT,
|
||||
"description": "All checks passed",
|
||||
"target_url": "https://gitea.example/acme/hub/actions/runs/42",
|
||||
"created_at": "2026-08-21T20:05:00Z",
|
||||
}
|
||||
]
|
||||
self._file = "# ITWorx MCP Hub\n\nThis is a safe fixture.\n"
|
||||
self._reviews: list[dict[str, Any]] = []
|
||||
self._review_comments = [
|
||||
{"id": 12, "path": "README.md", "new_position": 1, "body": "Clarify this."}
|
||||
]
|
||||
self._action_run = {
|
||||
"id": 42,
|
||||
"name": "ChatGPT validation",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
"event": "pull_request",
|
||||
"head_branch": "codex/workflows",
|
||||
"head_sha": "e" * 40,
|
||||
"workflow_id": "managed-validation.yml",
|
||||
"html_url": "https://gitea.example/acme/hub/actions/runs/42",
|
||||
}
|
||||
self._action_job = {
|
||||
"id": 43,
|
||||
"name": "full",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
}
|
||||
self._action_job_logs = "all checks passed\n"
|
||||
|
||||
def transport(self) -> httpx.MockTransport:
|
||||
return httpx.MockTransport(self._handle)
|
||||
|
||||
def _handle(self, request: httpx.Request) -> httpx.Response:
|
||||
self.recorded_methods.append(request.method)
|
||||
self.recorded_authorizations.append(request.headers.get("authorization", ""))
|
||||
if self.redirect_to is not None:
|
||||
return httpx.Response(302, headers={"location": self.redirect_to}, request=request)
|
||||
parsed = urlparse(str(request.url))
|
||||
self.recorded_paths.append(parsed.path)
|
||||
if not parsed.path.startswith("/api/v1/"):
|
||||
return self._json(404, {"message": "API prefix missing"}, request)
|
||||
path = parsed.path.removeprefix("/api/v1/")
|
||||
query = parse_qs(parsed.query)
|
||||
body: dict[str, Any] = {}
|
||||
if request.content:
|
||||
decoded = json.loads(request.content.decode("utf-8"))
|
||||
body = decoded if isinstance(decoded, dict) else {}
|
||||
self.recorded_bodies.append(body)
|
||||
if path == "version" and request.method == "GET":
|
||||
return self._json(200, {"version": self.gitea_version}, request)
|
||||
if path == "repos/search":
|
||||
if self.unsupported_search:
|
||||
return self._json(501, {"message": "unsupported"}, request)
|
||||
if "q" not in query:
|
||||
repositories = list(self._repos.values())
|
||||
if query.get("private") == ["true"]:
|
||||
repositories = [item for item in repositories if item.get("private") is True]
|
||||
return self._json(200, {"ok": True, "data": repositories}, request)
|
||||
return self._json(
|
||||
200,
|
||||
{
|
||||
"ok": True,
|
||||
"data": [{"repository": {"full_name": "acme/hub"}, "path": "README.md"}],
|
||||
},
|
||||
request,
|
||||
)
|
||||
if path == "user" and request.method == "GET":
|
||||
return self._json(200, {"login": "acme"}, request)
|
||||
if path == "user/repos" and request.method == "GET":
|
||||
if self.deny_user_repositories:
|
||||
return self._json(403, {"message": "denied"}, request)
|
||||
return self._paged(list(self._repos.values()), query, request)
|
||||
if path == "user/repos" and request.method == "POST":
|
||||
name = str(body.get("name", ""))
|
||||
qualified_name = f"acme/{name}"
|
||||
if qualified_name in self._repos:
|
||||
return self._json(409, {"message": "repository already exists"}, request)
|
||||
auto_init = bool(body.get("auto_init", True))
|
||||
repository = {
|
||||
"owner": {"login": "acme"},
|
||||
"name": name,
|
||||
"full_name": qualified_name,
|
||||
"description": str(body.get("description", "")),
|
||||
"private": bool(body.get("private", True)),
|
||||
"empty": not auto_init,
|
||||
"default_branch": str(body.get("default_branch", "main")) if auto_init else "",
|
||||
"html_url": f"https://gitea.example/{qualified_name}",
|
||||
"updated_at": "2026-08-26T15:00:00Z",
|
||||
}
|
||||
self._repos[qualified_name] = repository
|
||||
return self._json(201, repository, request)
|
||||
if path == "repos/issues/search" and request.method == "GET":
|
||||
state = str(query.get("state", ["open"])[0])
|
||||
issues = [
|
||||
{
|
||||
**item,
|
||||
"repository": {
|
||||
"full_name": "acme/hub",
|
||||
"name": "hub",
|
||||
"owner": {"login": "acme"},
|
||||
},
|
||||
"updated_at": "2026-08-01T10:00:00Z",
|
||||
"labels": [],
|
||||
}
|
||||
for item in self._issues
|
||||
if state == "all" or item.get("state") == state
|
||||
]
|
||||
return self._paged(issues, query, request)
|
||||
if path.startswith("repos/"):
|
||||
path = path.removeprefix("repos/")
|
||||
parts = path.split("/")
|
||||
qualified_name = "/".join(parts[:2])
|
||||
if len(parts) >= 2 and qualified_name not in self._repos:
|
||||
return self._json(404, {"message": "not found"}, request)
|
||||
if len(parts) == 2:
|
||||
if request.method == "DELETE":
|
||||
self._repos.pop(qualified_name, None)
|
||||
self._branch_protections.pop(qualified_name, None)
|
||||
self._managed_workflows.pop(qualified_name, None)
|
||||
return httpx.Response(204, request=request)
|
||||
if request.method != "GET":
|
||||
return self._json(405, {"message": "method not allowed"}, request)
|
||||
return self._json(200, self._repos[qualified_name], request, leaked_header=True)
|
||||
if parts[2] == "branch_protections":
|
||||
protections = self._branch_protections.setdefault(qualified_name, [])
|
||||
if len(parts) == 3:
|
||||
if request.method == "POST":
|
||||
created = dict(body)
|
||||
created.setdefault("rule_name", str(body.get("branch_name", "")))
|
||||
created.setdefault("branch_name", str(body.get("rule_name", "")))
|
||||
protections.append(created)
|
||||
branch_name = str(created.get("rule_name") or created.get("branch_name"))
|
||||
for branch in self._branches:
|
||||
if branch.get("name") == branch_name:
|
||||
branch["protected"] = True
|
||||
return self._json(201, created, request)
|
||||
return self._json(200, protections, request)
|
||||
rule_name = unquote(parts[3])
|
||||
protection = next(
|
||||
(
|
||||
item
|
||||
for item in protections
|
||||
if item.get("rule_name") == rule_name or item.get("branch_name") == rule_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
if protection is None:
|
||||
return self._json(404, {"message": "not found"}, request)
|
||||
if request.method == "PATCH":
|
||||
protection.update(body)
|
||||
return self._json(200, protection, request)
|
||||
if request.method == "DELETE":
|
||||
protections.remove(protection)
|
||||
return httpx.Response(204, request=request)
|
||||
return self._json(200, protection, request)
|
||||
if parts[2] == "branches":
|
||||
if request.method == "POST":
|
||||
created = {
|
||||
"name": body.get("new_branch_name", ""),
|
||||
"commit": {"id": "f" * 40},
|
||||
"protected": False,
|
||||
}
|
||||
self._branches.append(created)
|
||||
return self._json(201, created, request)
|
||||
if len(parts) == 4:
|
||||
branch_name = unquote(parts[3])
|
||||
matched_branch: dict[str, object] | None = None
|
||||
for item in self._branches:
|
||||
if item.get("name") == branch_name:
|
||||
matched_branch = item
|
||||
break
|
||||
if matched_branch is None:
|
||||
return self._json(404, {"message": "not found"}, request)
|
||||
if request.method == "DELETE":
|
||||
self._branches.remove(matched_branch)
|
||||
return httpx.Response(204, request=request)
|
||||
return self._json(200, matched_branch, request)
|
||||
return self._paged(self._branches, query, request)
|
||||
if parts[2] == "commits" and len(parts) == 3:
|
||||
return self._paged(self._commits, query, request)
|
||||
if parts[2:4] == ["git", "commits"] and len(parts) == 5:
|
||||
return self._json(
|
||||
200,
|
||||
{
|
||||
**self._commits[0],
|
||||
"sha": unquote(parts[4]),
|
||||
"verification": {"verified": True, "reason": "gpg"},
|
||||
"stats": {"additions": 42, "deletions": 2, "total": 44},
|
||||
"files": [{"filename": "README.md", "status": "modified"}],
|
||||
"html_url": "https://gitea.example/acme/hub/commit/example",
|
||||
},
|
||||
request,
|
||||
)
|
||||
if parts[2] == "compare" and len(parts) == 4:
|
||||
compare_range = unquote(parts[3])
|
||||
reverse_ranges: set[str] = set()
|
||||
for pull in self._pulls:
|
||||
head = pull.get("head")
|
||||
base = pull.get("base")
|
||||
if isinstance(head, dict) and isinstance(base, dict):
|
||||
reverse_ranges.add(f"{head.get('sha', '')}...{base.get('sha', '')}")
|
||||
compare_commits = (
|
||||
self._reverse_compare_commits
|
||||
if compare_range in reverse_ranges
|
||||
else self._compare_commits
|
||||
)
|
||||
return self._json(
|
||||
200,
|
||||
{
|
||||
"total_commits": len(compare_commits),
|
||||
"commits": compare_commits,
|
||||
"files": [{"filename": "README.md", "status": "modified"}],
|
||||
},
|
||||
request,
|
||||
)
|
||||
if parts[2] == "releases":
|
||||
return self._paged(self._releases, query, request)
|
||||
if parts[2] == "issues":
|
||||
if request.method == "POST" and len(parts) == 3:
|
||||
created_issue = {
|
||||
"number": len(self._issues) + 1,
|
||||
"title": body.get("title", ""),
|
||||
"state": "open",
|
||||
"html_url": f"https://gitea.example/acme/hub/issues/{len(self._issues) + 1}",
|
||||
}
|
||||
self._issues.append(created_issue)
|
||||
return self._json(201, created_issue, request)
|
||||
if len(parts) == 4:
|
||||
issue_number = int(parts[3])
|
||||
issue = next(
|
||||
(item for item in self._issues if item.get("number") == issue_number), None
|
||||
)
|
||||
if issue is None:
|
||||
return self._json(404, {"message": "not found"}, request)
|
||||
if request.method == "PATCH":
|
||||
issue.update(
|
||||
{
|
||||
key: value
|
||||
for key, value in body.items()
|
||||
if key in {"title", "body", "state"}
|
||||
}
|
||||
)
|
||||
return self._json(200, issue, request)
|
||||
if request.method == "POST" and len(parts) == 5 and parts[4] == "comments":
|
||||
return self._json(
|
||||
201,
|
||||
{
|
||||
"id": 99,
|
||||
"body": body.get("body", ""),
|
||||
"html_url": f"https://gitea.example/acme/hub/issues/{parts[3]}#issuecomment-99",
|
||||
},
|
||||
request,
|
||||
)
|
||||
return self._paged(self._issues, query, request)
|
||||
if parts[2] == "pulls" and len(parts) == 3:
|
||||
if request.method == "POST":
|
||||
created_pull = {
|
||||
"number": len(self._pulls) + 8,
|
||||
"title": body.get("title", ""),
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
"mergeable": True,
|
||||
"html_url": "https://gitea.example/acme/hub/pulls/8",
|
||||
"head": {"ref": body.get("head", ""), "sha": "f" * 40},
|
||||
"base": {"ref": body.get("base", ""), "sha": "a" * 40},
|
||||
"updated_at": "2026-08-24T20:00:00Z",
|
||||
}
|
||||
self._pulls.append(created_pull)
|
||||
return self._json(201, created_pull, request)
|
||||
return self._paged(self._pulls, query, request)
|
||||
if parts[2] == "pulls" and len(parts) == 4 and parts[3] == "7":
|
||||
if request.method == "PATCH":
|
||||
self._pulls[0].update(
|
||||
{key: value for key, value in body.items() if key in {"title", "body", "state"}}
|
||||
)
|
||||
if body.get("state") == "open":
|
||||
self._action_run.update(
|
||||
event="pull_request", status="queued", conclusion="",
|
||||
head_sha=self._pulls[0]["head"]["sha"],
|
||||
)
|
||||
return self._json(200, self._pulls[0], request)
|
||||
if parts[2] == "pulls" and len(parts) == 5 and parts[3:] == ["7", "update"]:
|
||||
if query.get("style") not in [["merge"], ["rebase"]]:
|
||||
return self._json(422, {"message": "invalid style"}, request)
|
||||
head = self._pulls[0].get("head")
|
||||
base = self._pulls[0].get("base")
|
||||
if not isinstance(head, dict) or not isinstance(base, dict):
|
||||
return self._json(500, {"message": "invalid fake pull request"}, request)
|
||||
head["sha"] = "d" * 40
|
||||
base["sha"] = "a" * 40
|
||||
return self._json(200, {}, request)
|
||||
if parts[2] == "pulls" and len(parts) == 5 and parts[3:] == ["7", "merge"]:
|
||||
if self.deny_merge_transition:
|
||||
return self._json(405, {"message": "merge blocked by repository policy"}, request)
|
||||
strategy_key = "Do" if self.gitea_version.startswith(("1.14.", "1.25.")) else "do"
|
||||
if body.get(strategy_key) not in {
|
||||
"merge",
|
||||
"rebase",
|
||||
"rebase-merge",
|
||||
"squash",
|
||||
"fast-forward-only",
|
||||
}:
|
||||
return self._json(405, {"message": "unsupported merge strategy"}, request)
|
||||
self._pulls[0].update({"state": "closed", "merged": True, "merge_commit_sha": "6" * 40})
|
||||
for branch in self._branches:
|
||||
if branch.get("name") == "main":
|
||||
branch["commit"]["id"] = "6" * 40
|
||||
return self._json(200, {}, request)
|
||||
if parts[2] == "pulls" and len(parts) == 5 and parts[3:] == ["7", "files"]:
|
||||
return self._paged(self._pull_files, query, request)
|
||||
if parts[2] == "pulls" and len(parts) == 5 and parts[3:] == ["7", "reviews"]:
|
||||
return self._paged(self._reviews, query, request)
|
||||
if (
|
||||
parts[2] == "pulls"
|
||||
and len(parts) == 7
|
||||
and parts[3:5] == ["7", "reviews"]
|
||||
and parts[6] == "comments"
|
||||
):
|
||||
return self._json(200, self._review_comments, request)
|
||||
if parts[2] == "commits" and len(parts) == 5 and parts[4] == "statuses":
|
||||
return self._paged(self._statuses, query, request)
|
||||
if parts[2:4] == ["git", "trees"] and len(parts) == 5:
|
||||
if self.unsupported_search:
|
||||
return self._json(501, {"message": "unsupported"}, request)
|
||||
return self._json(
|
||||
200,
|
||||
{
|
||||
"sha": "a" * 40,
|
||||
"tree": [
|
||||
{
|
||||
"path": "README.md",
|
||||
"type": "blob",
|
||||
"sha": "d" * 40,
|
||||
"size": len(self._file.encode()),
|
||||
}
|
||||
],
|
||||
"truncated": False,
|
||||
},
|
||||
request,
|
||||
)
|
||||
if parts[2:4] == ["git", "blobs"] and len(parts) == 5:
|
||||
return self._json(
|
||||
200,
|
||||
{
|
||||
"sha": parts[4],
|
||||
"encoding": "base64",
|
||||
"content": base64.b64encode(self._file.encode()).decode(),
|
||||
"size": len(self._file.encode()),
|
||||
},
|
||||
request,
|
||||
)
|
||||
if parts[2:4] == ["actions", "workflows"] and len(parts) == 4:
|
||||
workflows = []
|
||||
if qualified_name in self._managed_workflows:
|
||||
workflows.append(
|
||||
{
|
||||
"id": "managed-validation.yml",
|
||||
"name": "Managed validation",
|
||||
"path": MANAGED_VALIDATION_PATH,
|
||||
}
|
||||
)
|
||||
return self._json(
|
||||
200,
|
||||
{"workflows": workflows},
|
||||
request,
|
||||
)
|
||||
if parts[2:4] == ["actions", "workflows"] and len(parts) == 6 and parts[5] == "dispatches":
|
||||
raw_inputs = body.get("inputs")
|
||||
inputs: dict[str, Any] = raw_inputs if isinstance(raw_inputs, dict) else {}
|
||||
profile = str(inputs.get("profile", "full"))
|
||||
ref = str(body.get("ref", ""))
|
||||
dispatch_branch: dict[str, Any] | None = next(
|
||||
(item for item in self._branches if item.get("name") == ref), None
|
||||
)
|
||||
head_sha = str(
|
||||
(dispatch_branch or {}).get("commit", {}).get("id", self._action_run["head_sha"])
|
||||
)
|
||||
self._action_run.update(
|
||||
{
|
||||
"event": "workflow_dispatch",
|
||||
"head_branch": ref,
|
||||
"head_sha": head_sha,
|
||||
"workflow_id": unquote(parts[4]),
|
||||
"display_title": f"Managed validation / {profile}",
|
||||
}
|
||||
)
|
||||
self._action_job["name"] = "full"
|
||||
return self._json(200, self._action_run, request)
|
||||
if parts[2:4] == ["actions", "runs"] and len(parts) == 4:
|
||||
return self._json(200, {"workflow_runs": [self._action_run]}, request)
|
||||
if parts[2:4] == ["actions", "runs"] and len(parts) == 6 and parts[5] == "rerun":
|
||||
self._action_run.update(status="queued", conclusion="")
|
||||
return httpx.Response(204, request=request)
|
||||
if parts[2:4] == ["actions", "runs"] and len(parts) == 5:
|
||||
return self._json(200, self._action_run, request)
|
||||
if parts[2:4] == ["actions", "runs"] and len(parts) == 6 and parts[5] == "jobs":
|
||||
return self._json(200, {"jobs": [self._action_job]}, request)
|
||||
if parts[2:4] == ["actions", "jobs"] and len(parts) == 6 and parts[5] == "logs":
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/plain"},
|
||||
content=self._action_job_logs.encode(),
|
||||
request=request,
|
||||
)
|
||||
if parts[2] == "contents" and len(parts) == 3:
|
||||
return self._json(
|
||||
200,
|
||||
[
|
||||
{
|
||||
"name": "README.md",
|
||||
"path": "README.md",
|
||||
"type": "file",
|
||||
"size": len(self._file.encode()),
|
||||
"sha": "d" * 40,
|
||||
}
|
||||
],
|
||||
request,
|
||||
)
|
||||
if (
|
||||
parts[2] == "contents"
|
||||
and len(parts) >= 4
|
||||
and "/".join(parts[3:]) == MANAGED_VALIDATION_PATH
|
||||
):
|
||||
existing = self._managed_workflows.get(qualified_name)
|
||||
protection = next(iter(self._branch_protections.get(qualified_name, [])), None)
|
||||
temporary_push_allowed = bool(
|
||||
protection
|
||||
and protection.get("enable_push") is True
|
||||
and protection.get("enable_push_whitelist") is True
|
||||
and "acme" in protection.get("push_whitelist_usernames", [])
|
||||
)
|
||||
if (
|
||||
request.method in {"POST", "PUT"}
|
||||
and self.deny_protected_workflow_writes
|
||||
and protection is not None
|
||||
and not temporary_push_allowed
|
||||
):
|
||||
return self._json(403, {"message": "protected branch"}, request)
|
||||
if request.method == "POST":
|
||||
if existing is not None:
|
||||
return self._json(422, {"message": "already exists"}, request)
|
||||
self._managed_workflows[qualified_name] = base64.b64decode(
|
||||
str(body.get("content", ""))
|
||||
).decode()
|
||||
return self._json(
|
||||
201,
|
||||
{"content": {"path": MANAGED_VALIDATION_PATH, "sha": "9" * 40}},
|
||||
request,
|
||||
)
|
||||
if existing is None:
|
||||
return self._json(404, {"message": "not found"}, request)
|
||||
if request.method == "PUT":
|
||||
self._managed_workflows[qualified_name] = base64.b64decode(
|
||||
str(body.get("content", ""))
|
||||
).decode()
|
||||
return self._json(
|
||||
200,
|
||||
{"content": {"path": MANAGED_VALIDATION_PATH, "sha": "9" * 40}},
|
||||
request,
|
||||
)
|
||||
if request.method == "DELETE":
|
||||
self._managed_workflows.pop(qualified_name, None)
|
||||
return self._json(200, {"commit": {"sha": "7" * 40}}, request)
|
||||
return self._json(
|
||||
200,
|
||||
{
|
||||
"name": "managed-validation.yml",
|
||||
"path": MANAGED_VALIDATION_PATH,
|
||||
"sha": "d" * 40,
|
||||
"content": base64.b64encode(existing.encode()).decode(),
|
||||
"encoding": "base64",
|
||||
},
|
||||
request,
|
||||
)
|
||||
if parts[2] == "contents" and len(parts) >= 4 and "/".join(parts[3:]) == "README.md":
|
||||
if request.method == "DELETE":
|
||||
return self._json(200, {"commit": {"sha": "7" * 40}}, request)
|
||||
if request.method in {"POST", "PUT"}:
|
||||
if request.method == "POST" and self._repos[qualified_name].get("empty"):
|
||||
branch_name = str(body.get("branch", "main"))
|
||||
self._repos[qualified_name]["empty"] = False
|
||||
self._repos[qualified_name]["default_branch"] = branch_name
|
||||
if not any(item.get("name") == branch_name for item in self._branches):
|
||||
self._branches.append(
|
||||
{
|
||||
"name": branch_name,
|
||||
"commit": {"id": "8" * 40},
|
||||
"protected": False,
|
||||
}
|
||||
)
|
||||
return self._json(
|
||||
201 if request.method == "POST" else 200,
|
||||
{
|
||||
"content": {"path": "README.md", "sha": "9" * 40},
|
||||
"commit": {"sha": "8" * 40},
|
||||
},
|
||||
request,
|
||||
)
|
||||
return self._json(
|
||||
200,
|
||||
{
|
||||
"name": "README.md",
|
||||
"path": "README.md",
|
||||
"sha": "d" * 40,
|
||||
"content": base64.b64encode(self._file.encode()).decode(),
|
||||
"encoding": "base64",
|
||||
"token": "fixture-token-must-not-leak",
|
||||
},
|
||||
request,
|
||||
)
|
||||
return self._json(404, {"message": "not found"}, request)
|
||||
|
||||
def _paged(
|
||||
self, items: list[dict[str, Any]], query: dict[str, list[str]], request: httpx.Request
|
||||
) -> httpx.Response:
|
||||
page = max(int(query.get("page", ["1"])[0]), 1)
|
||||
limit = max(int(query.get("limit", ["100"])[0]), 1)
|
||||
start = (page - 1) * limit
|
||||
return self._json(200, items[start : start + limit], request)
|
||||
|
||||
@staticmethod
|
||||
def _json(
|
||||
status: int, payload: object, request: httpx.Request, *, leaked_header: bool = False
|
||||
) -> httpx.Response:
|
||||
headers = {"content-type": "application/json"}
|
||||
if leaked_header:
|
||||
headers["x-fixture-token"] = "fixture-token-must-not-leak"
|
||||
return httpx.Response(
|
||||
status, headers=headers, content=json.dumps(payload).encode(), request=request
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Bounded helpers for destructive Gitea pull-request lifecycle cleanup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Protocol
|
||||
|
||||
from packages.connector_kit import GiteaError, RegisteredRepository
|
||||
|
||||
_VIRTUAL_PULL_HEAD = re.compile(r"refs/pull/[1-9][0-9]*/head")
|
||||
|
||||
|
||||
class PullRequestReader(Protocol):
|
||||
def pull_requests(
|
||||
self, repository: RegisteredRepository, page: int, limit: int
|
||||
) -> list[dict[str, Any]]: ...
|
||||
|
||||
|
||||
def cleanup_basis(
|
||||
client: PullRequestReader,
|
||||
repository: RegisteredRepository,
|
||||
branch: str,
|
||||
expected_head_sha: str,
|
||||
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||
"""Return the merged/closed PR proving one exact branch is cleanup-eligible."""
|
||||
related: list[dict[str, Any]] = []
|
||||
for page_number in range(1, 11):
|
||||
pulls = client.pull_requests(repository, page_number, 100)
|
||||
for item in pulls:
|
||||
head = item.get("head") or {}
|
||||
head_ref = str(head.get("ref", ""))
|
||||
head_sha = str(head.get("sha", ""))
|
||||
if head_ref == branch or (
|
||||
head_sha == expected_head_sha and _VIRTUAL_PULL_HEAD.fullmatch(head_ref)
|
||||
):
|
||||
related.append(item)
|
||||
if len(pulls) < 100:
|
||||
break
|
||||
|
||||
if any(str(item.get("state", "")) == "open" for item in related):
|
||||
raise GiteaError(
|
||||
"GITEA_TARGET_INVALID",
|
||||
"branch is still referenced by an open pull request; close or merge it first",
|
||||
)
|
||||
merged = next((item for item in related if bool(item.get("merged", False))), None)
|
||||
closed = next(
|
||||
(
|
||||
item
|
||||
for item in related
|
||||
if str(item.get("state", "")) == "closed" and not bool(item.get("merged", False))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if merged is None and closed is None:
|
||||
raise GiteaError(
|
||||
"GITEA_TARGET_INVALID",
|
||||
"only a change branch from a merged or closed pull request can be deleted",
|
||||
)
|
||||
return merged, closed
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Stable managed-validation contract used by Gitea governance tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
MANAGED_VALIDATION_PATH = ".gitea/workflows/managed-validation.yml"
|
||||
MANAGED_VALIDATION_CONTEXT = "Managed validation / full (pull_request)"
|
||||
|
||||
# Keep this workflow pull-request/dispatch only. Bulk governance must never create
|
||||
# a push-triggered Actions storm across the portfolio.
|
||||
MANAGED_VALIDATION_WORKFLOW = """name: Managed validation
|
||||
run-name: "Managed validation / ${{ inputs.profile || 'full' }}"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: Allowlisted validation profile
|
||||
required: true
|
||||
default: full
|
||||
type: choice
|
||||
options: [test, lint, typecheck, build, security, full]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: managed-validation-${{ gitea.repository }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
full:
|
||||
name: full
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install .NET 8 for solution repositories
|
||||
if: ${{ hashFiles('*.sln') != '' }}
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
- name: Validate repository with a bounded profile
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_PROFILE: ${{ inputs.profile }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
profile="${REQUESTED_PROFILE:-full}"
|
||||
case "${profile}" in
|
||||
test|lint|typecheck|build|security|full) ;;
|
||||
*) echo "Profile is not allowlisted" >&2; exit 2 ;;
|
||||
esac
|
||||
python_tests_ran=0
|
||||
|
||||
git diff --check
|
||||
if git grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then
|
||||
echo "Unresolved merge markers detected" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -f pyproject.toml || -f requirements.txt ]]; then
|
||||
# Compile only tracked Python sources. Running compileall after a
|
||||
# Node install would otherwise traverse node_modules and turn a
|
||||
# lightweight baseline into a large runner workload.
|
||||
git ls-files -z '*.py' | xargs -0 -r python -m py_compile
|
||||
if [[ -f uv.lock ]]; then
|
||||
python -m venv "${RUNNER_TEMP}/managed-uv"
|
||||
uv_python="${RUNNER_TEMP}/managed-uv/bin/python"
|
||||
"${uv_python}" -m pip install --disable-pip-version-check uv==0.10.0
|
||||
managed_uv="${RUNNER_TEMP}/managed-uv/bin/uv"
|
||||
export UV_PROJECT_ENVIRONMENT="${RUNNER_TEMP}/managed-project-venv"
|
||||
"${managed_uv}" sync --locked
|
||||
export PATH="${UV_PROJECT_ENVIRONMENT}/bin:${PATH}"
|
||||
if [[ "${profile}" == test || "${profile}" == full ]]; then
|
||||
if "${managed_uv}" run python -c 'import pytest' 2>/dev/null; then
|
||||
"${managed_uv}" run python -m pytest
|
||||
python_tests_ran=1
|
||||
fi
|
||||
fi
|
||||
if [[ "${profile}" == lint || "${profile}" == full ]]; then
|
||||
if "${managed_uv}" run python -c 'import ruff' 2>/dev/null; then
|
||||
"${managed_uv}" run python -m ruff check .
|
||||
fi
|
||||
fi
|
||||
elif [[ -f requirements.txt ]]; then
|
||||
python -m venv "${RUNNER_TEMP}/managed-python"
|
||||
managed_python="${RUNNER_TEMP}/managed-python/bin/python"
|
||||
"${managed_python}" -m pip install --disable-pip-version-check -r requirements.txt
|
||||
export PATH="${RUNNER_TEMP}/managed-python/bin:${PATH}"
|
||||
if [[ "${profile}" == test || "${profile}" == full ]]; then
|
||||
if "${managed_python}" -c 'import pytest' 2>/dev/null; then
|
||||
"${managed_python}" -m pytest
|
||||
python_tests_ran=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Prepare Python before invoking Node scripts. Polyglot repositories
|
||||
# commonly delegate their test script to Python and need the managed
|
||||
# virtual environment to be active first.
|
||||
if [[ -f package.json ]]; then
|
||||
corepack enable
|
||||
run_node_test=1
|
||||
if [[ "${python_tests_ran}" == 1 ]] && node -e '
|
||||
const script = String(require("./package.json").scripts?.test || "");
|
||||
const delegated = /^\\s*python(?:3)?\\s+-m\\s+(?:pytest|unittest)\\b/.test(script);
|
||||
process.exit(delegated ? 0 : 1);
|
||||
'; then
|
||||
run_node_test=0
|
||||
fi
|
||||
if [[ -f pnpm-lock.yaml ]]; then
|
||||
pnpm install --frozen-lockfile
|
||||
if [[ "${run_node_test}" == 1 ]]; then
|
||||
[[ "${profile}" == test || "${profile}" == full ]] && pnpm --if-present test
|
||||
fi
|
||||
[[ "${profile}" == lint || "${profile}" == full ]] && pnpm --if-present lint
|
||||
[[ "${profile}" == typecheck || "${profile}" == full ]] && pnpm --if-present typecheck
|
||||
[[ "${profile}" == build || "${profile}" == full ]] && pnpm --if-present build
|
||||
elif [[ -f package-lock.json ]]; then
|
||||
npm ci
|
||||
if [[ "${run_node_test}" == 1 ]]; then
|
||||
[[ "${profile}" == test || "${profile}" == full ]] && npm run --if-present test
|
||||
fi
|
||||
[[ "${profile}" == lint || "${profile}" == full ]] && npm run --if-present lint
|
||||
if [[ "${profile}" == typecheck || "${profile}" == full ]]; then
|
||||
npm run --if-present typecheck
|
||||
fi
|
||||
[[ "${profile}" == build || "${profile}" == full ]] && npm run --if-present build
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f go.mod ]]; then
|
||||
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||
go test ./...
|
||||
fi
|
||||
fi
|
||||
if [[ -f Cargo.toml ]]; then
|
||||
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||
cargo test --locked
|
||||
fi
|
||||
fi
|
||||
if compgen -G '*.sln' >/dev/null; then
|
||||
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||
dotnet test --configuration Release
|
||||
fi
|
||||
fi
|
||||
"""
|
||||
|
||||
MANAGED_VALIDATION_DIGEST = hashlib.sha256(MANAGED_VALIDATION_WORKFLOW.encode("utf-8")).hexdigest()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,5 @@
|
||||
"""Project Knowledge connector."""
|
||||
|
||||
from .server import KNOWLEDGE_RESOURCES, KNOWLEDGE_TOOLS, KnowledgeConnector
|
||||
|
||||
__all__ = ["KNOWLEDGE_RESOURCES", "KNOWLEDGE_TOOLS", "KnowledgeConnector"]
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Project Knowledge connector surface: read-only, scoped, source-backed MCP contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from packages.connector_kit import (
|
||||
ConnectorContext,
|
||||
InMemoryKnowledgeIndex,
|
||||
PageRequest,
|
||||
ResourcePublication,
|
||||
SourceType,
|
||||
ToolPublication,
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
)
|
||||
from packages.connector_kit.knowledge import DocumentNotFound
|
||||
from packages.hub_policy import Governance, governed_tool
|
||||
|
||||
|
||||
class KnowledgeConnector:
|
||||
"""Expose only registered project documents; no physical path is an input."""
|
||||
|
||||
connector_type = "knowledge"
|
||||
version = "1.0.0"
|
||||
|
||||
def __init__(self, index: InMemoryKnowledgeIndex, governance: Governance | None = None) -> None:
|
||||
self.index = index
|
||||
self.governance = governance
|
||||
|
||||
def _envelope(
|
||||
self,
|
||||
context: ConnectorContext,
|
||||
data: Any,
|
||||
*,
|
||||
truncated: bool = False,
|
||||
next_cursor: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"data": data,
|
||||
"meta": {
|
||||
"project": context.project_slug,
|
||||
"environment": context.environment,
|
||||
"source": "knowledge",
|
||||
"truncated": truncated,
|
||||
"nextCursor": next_cursor,
|
||||
"generatedAt": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _hit_payload(item: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"uri": item.uri,
|
||||
"title": item.title,
|
||||
"path": item.path,
|
||||
"snippet": item.snippet,
|
||||
"sourceType": item.source_type,
|
||||
"sourceRevision": item.source_revision,
|
||||
"repositoryRef": item.repository_ref,
|
||||
"repositoryCommit": item.repository_commit,
|
||||
"contentHash": item.content_hash,
|
||||
"modifiedAt": item.modified_at.isoformat(),
|
||||
"score": item.score,
|
||||
"truncated": item.truncated,
|
||||
}
|
||||
|
||||
@governed_tool("knowledge.document.search")
|
||||
def document_search(
|
||||
self,
|
||||
context: ConnectorContext,
|
||||
query: str,
|
||||
*,
|
||||
source_types: Iterable[SourceType] = (),
|
||||
page: PageRequest | None = None,
|
||||
) -> dict[str, Any]:
|
||||
request = page or PageRequest()
|
||||
result = self.index.search(
|
||||
context.project_id,
|
||||
query,
|
||||
source_types=source_types,
|
||||
limit=min(request.limit, 20),
|
||||
cursor=request.cursor,
|
||||
)
|
||||
data = {
|
||||
"items": [self._hit_payload(item) for item in result.items],
|
||||
"nextCursor": result.next_cursor,
|
||||
"truncated": result.truncated,
|
||||
}
|
||||
return self._envelope(
|
||||
context, data, truncated=result.truncated, next_cursor=result.next_cursor
|
||||
)
|
||||
|
||||
@governed_tool("knowledge.document.get")
|
||||
def document_get(
|
||||
self, context: ConnectorContext, uri: str, *, max_bytes: int = 200_000
|
||||
) -> dict[str, Any]:
|
||||
if not 1 <= max_bytes <= 262_144:
|
||||
raise ValueError("max_bytes is outside the connector output budget")
|
||||
document = self.index.get_document(context.project_id, uri)
|
||||
encoded = document.content.encode("utf-8")
|
||||
truncated = len(encoded) > max_bytes
|
||||
content = (
|
||||
encoded[:max_bytes].decode("utf-8", errors="ignore") if truncated else document.content
|
||||
)
|
||||
data = {
|
||||
"uri": document.uri,
|
||||
"title": document.title,
|
||||
"mimeType": document.mime_type,
|
||||
"content": content,
|
||||
"path": document.external_ref,
|
||||
"sourceType": document.source_type,
|
||||
"sourceRevision": document.source_revision,
|
||||
"repositoryRef": document.repository_ref,
|
||||
"repositoryCommit": document.repository_commit,
|
||||
"contentHash": document.content_hash,
|
||||
"modifiedAt": document.modified_at.isoformat(),
|
||||
"truncated": truncated,
|
||||
}
|
||||
return self._envelope(context, data, truncated=truncated)
|
||||
|
||||
@governed_tool("knowledge.source.list")
|
||||
def source_list(
|
||||
self,
|
||||
context: ConnectorContext,
|
||||
*,
|
||||
source_type: SourceType | None = None,
|
||||
page: PageRequest | None = None,
|
||||
) -> dict[str, Any]:
|
||||
request = page or PageRequest(limit=25)
|
||||
sources = [
|
||||
source
|
||||
for source in self.index.list_sources(context.project_id)
|
||||
if source_type is None or source.source_type == source_type
|
||||
]
|
||||
cursor = decode_cursor(
|
||||
request.cursor, str(context.project_id), "sources", str(source_type or "")
|
||||
)
|
||||
items = sources[cursor.offset : cursor.offset + min(request.limit, 100)]
|
||||
offset = cursor.offset + len(items)
|
||||
next_cursor = (
|
||||
encode_cursor(offset, str(context.project_id), "sources", str(source_type or ""))
|
||||
if offset < len(sources)
|
||||
else None
|
||||
)
|
||||
data = {
|
||||
"items": [
|
||||
{
|
||||
"sourceRef": item.source_ref,
|
||||
"sourceType": item.source_type,
|
||||
"uri": item.uri,
|
||||
"status": item.status,
|
||||
"sourceRevision": item.source_revision,
|
||||
"lastIndexedAt": item.last_indexed_at.isoformat()
|
||||
if item.last_indexed_at
|
||||
else None,
|
||||
"documentCount": item.document_count,
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
"nextCursor": next_cursor,
|
||||
"truncated": next_cursor is not None,
|
||||
}
|
||||
return self._envelope(
|
||||
context, data, truncated=next_cursor is not None, next_cursor=next_cursor
|
||||
)
|
||||
|
||||
@governed_tool("knowledge.index.status")
|
||||
def index_status(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
status = self.index.status(context.project_id)
|
||||
data = {
|
||||
"status": status.status,
|
||||
"sourceCount": status.source_count,
|
||||
"documentCount": status.document_count,
|
||||
"lastCompletedAt": status.last_completed_at.isoformat()
|
||||
if status.last_completed_at
|
||||
else None,
|
||||
"lastError": status.last_error,
|
||||
"generatedAt": datetime.now(UTC),
|
||||
}
|
||||
return self._envelope(context, data)
|
||||
|
||||
@governed_tool("knowledge.resource.get")
|
||||
def resource_get(self, context: ConnectorContext, uri: str) -> dict[str, Any]:
|
||||
if not uri.startswith("itworx-docs://"):
|
||||
raise DocumentNotFound("resource URI is not registered")
|
||||
return self.document_get(context, uri)
|
||||
|
||||
@governed_tool("knowledge.adr.list")
|
||||
def adr_list(
|
||||
self, context: ConnectorContext, *, page: PageRequest | None = None
|
||||
) -> dict[str, Any]:
|
||||
request = page or PageRequest(limit=25)
|
||||
documents = self.index.documents_for_source(context.project_id, SourceType.ADR)
|
||||
cursor = decode_cursor(request.cursor, str(context.project_id), "adr")
|
||||
items = documents[cursor.offset : cursor.offset + min(request.limit, 100)]
|
||||
offset = cursor.offset + len(items)
|
||||
next_cursor = (
|
||||
encode_cursor(offset, str(context.project_id), "adr")
|
||||
if offset < len(documents)
|
||||
else None
|
||||
)
|
||||
data = {
|
||||
"items": [
|
||||
{
|
||||
"uri": doc.uri,
|
||||
"number": doc.external_ref,
|
||||
"title": doc.title,
|
||||
"status": "indexed",
|
||||
"updatedAt": doc.modified_at.isoformat(),
|
||||
}
|
||||
for doc in items
|
||||
],
|
||||
"nextCursor": next_cursor,
|
||||
"truncated": next_cursor is not None,
|
||||
}
|
||||
return self._envelope(
|
||||
context, data, truncated=next_cursor is not None, next_cursor=next_cursor
|
||||
)
|
||||
|
||||
@governed_tool("knowledge.project.summary")
|
||||
def project_summary(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
status = self.index.status(context.project_id)
|
||||
sources = [source.uri for source in self.index.list_sources(context.project_id)]
|
||||
summary = (
|
||||
f"{status.document_count} indexed documents across "
|
||||
f"{status.source_count} registered sources."
|
||||
)
|
||||
return self._envelope(
|
||||
context,
|
||||
{
|
||||
"summary": summary,
|
||||
"sources": sources,
|
||||
"sourceRevision": status.last_completed_at.isoformat()
|
||||
if status.last_completed_at
|
||||
else "never",
|
||||
"generatedAt": datetime.now(UTC),
|
||||
},
|
||||
)
|
||||
|
||||
def tools(self) -> tuple[ToolPublication, ...]:
|
||||
return KNOWLEDGE_TOOLS
|
||||
|
||||
def resources(self) -> tuple[ResourcePublication, ...]:
|
||||
return KNOWLEDGE_RESOURCES
|
||||
|
||||
def invoke(self, tool: str, context: ConnectorContext, **arguments: Any) -> dict[str, Any]:
|
||||
methods: dict[str, Callable[..., dict[str, Any]]] = {
|
||||
"knowledge.document.search": self.document_search,
|
||||
"knowledge.document.get": self.document_get,
|
||||
"knowledge.source.list": self.source_list,
|
||||
"knowledge.index.status": self.index_status,
|
||||
"knowledge.adr.list": self.adr_list,
|
||||
"knowledge.project.summary": self.project_summary,
|
||||
"knowledge.resource.get": self.resource_get,
|
||||
}
|
||||
method = methods.get(tool)
|
||||
if method is None:
|
||||
raise ValueError("unknown knowledge tool")
|
||||
return method(context, **arguments)
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
return {"status": "healthy", "component": "knowledge-connector"}
|
||||
|
||||
def readiness(self) -> dict[str, Any]:
|
||||
return {"status": "ready", "component": "knowledge-connector"}
|
||||
|
||||
|
||||
_COMMON_EMPTY_INPUT = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {},
|
||||
"required": [],
|
||||
}
|
||||
_COMMON_PAGE = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
|
||||
"cursor": {"type": ["string", "null"], "maxLength": 500},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
_COMMON_OUTPUT = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"data": {"type": "object"},
|
||||
"meta": {"type": "object"},
|
||||
"warnings": {"type": "array"},
|
||||
},
|
||||
"required": ["data", "meta", "warnings"],
|
||||
}
|
||||
|
||||
KNOWLEDGE_TOOLS = (
|
||||
ToolPublication(
|
||||
canonical_name="knowledge.document.search",
|
||||
version="1.0.0",
|
||||
connector_type="knowledge",
|
||||
risk_class="R1",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"query": {"type": "string", "minLength": 1, "maxLength": 1000},
|
||||
"sourceTypes": {"type": "array", "maxItems": 8},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 20},
|
||||
"cursor": {"type": ["string", "null"], "maxLength": 500},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
output_schema=_COMMON_OUTPUT,
|
||||
required_scopes=("project:{project}:read",),
|
||||
),
|
||||
ToolPublication(
|
||||
canonical_name="knowledge.document.get",
|
||||
version="1.0.0",
|
||||
connector_type="knowledge",
|
||||
risk_class="R1",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"uri": {
|
||||
"type": "string",
|
||||
"pattern": "^itworx-docs://",
|
||||
"maxLength": 2048,
|
||||
}
|
||||
},
|
||||
"required": ["uri"],
|
||||
},
|
||||
output_schema=_COMMON_OUTPUT,
|
||||
required_scopes=("project:{project}:read",),
|
||||
),
|
||||
ToolPublication(
|
||||
canonical_name="knowledge.source.list",
|
||||
version="1.0.0",
|
||||
connector_type="knowledge",
|
||||
risk_class="R1",
|
||||
input_schema=_COMMON_PAGE,
|
||||
output_schema=_COMMON_OUTPUT,
|
||||
required_scopes=("project:{project}:read",),
|
||||
),
|
||||
ToolPublication(
|
||||
canonical_name="knowledge.index.status",
|
||||
version="1.0.0",
|
||||
connector_type="knowledge",
|
||||
risk_class="R1",
|
||||
input_schema=_COMMON_EMPTY_INPUT,
|
||||
output_schema=_COMMON_OUTPUT,
|
||||
required_scopes=("project:{project}:read",),
|
||||
),
|
||||
ToolPublication(
|
||||
canonical_name="knowledge.adr.list",
|
||||
version="1.0.0",
|
||||
connector_type="knowledge",
|
||||
risk_class="R1",
|
||||
input_schema=_COMMON_PAGE,
|
||||
output_schema=_COMMON_OUTPUT,
|
||||
required_scopes=("project:{project}:read",),
|
||||
),
|
||||
ToolPublication(
|
||||
canonical_name="knowledge.project.summary",
|
||||
version="1.0.0",
|
||||
connector_type="knowledge",
|
||||
risk_class="R1",
|
||||
input_schema=_COMMON_EMPTY_INPUT,
|
||||
output_schema=_COMMON_OUTPUT,
|
||||
required_scopes=("project:{project}:read",),
|
||||
),
|
||||
)
|
||||
|
||||
KNOWLEDGE_RESOURCES = (
|
||||
ResourcePublication(
|
||||
uri_template="itworx-docs://{project}/{source}/{documentId}",
|
||||
connector_type="knowledge",
|
||||
risk_class="R1",
|
||||
mime_types=("text/markdown", "text/plain", "application/json"),
|
||||
max_bytes=262_144,
|
||||
required_scopes=("project:{project}:read",),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Read-only MobilityOps (Fleet Ops) MCP connector."""
|
||||
|
||||
from .fake import FakeMobilityOpsServer
|
||||
from .server import MOBILITYOPS_TOOLS, MobilityOpsConnector
|
||||
|
||||
__all__ = ["MOBILITYOPS_TOOLS", "FakeMobilityOpsServer", "MobilityOpsConnector"]
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Deterministic in-memory MobilityOps HTTP transport for contract tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class FakeMobilityOpsServer:
|
||||
"""A tiny MobilityOps API fixture with observable requests and no network."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unauthorized: bool = False,
|
||||
redirect_to: str | None = None,
|
||||
vehicle_not_found: bool = False,
|
||||
unreachable: bool = False,
|
||||
) -> None:
|
||||
self.unauthorized = unauthorized
|
||||
self.redirect_to = redirect_to
|
||||
self.vehicle_not_found = vehicle_not_found
|
||||
self.unreachable = unreachable
|
||||
self.recorded_methods: list[str] = []
|
||||
self.recorded_service_tokens: list[str] = []
|
||||
self.recorded_client_ids: list[str] = []
|
||||
self.recorded_correlation_ids: list[str] = []
|
||||
self.recorded_locales: list[str | None] = []
|
||||
self._summary = {
|
||||
"tenant": "Northstar Mobility Demo",
|
||||
"metrics": {
|
||||
"available": 21,
|
||||
"rented": 11,
|
||||
"cleaning": 6,
|
||||
"maintenance": 5,
|
||||
"blocked": 7,
|
||||
"open_quality_issues": 3,
|
||||
"pending_or_failed_workflows": 1,
|
||||
},
|
||||
}
|
||||
self._attention = [
|
||||
{
|
||||
"vehicle_ref": "MO-016",
|
||||
"severity": "high",
|
||||
"rule_type": "vehicle_status_conflict",
|
||||
"summary": "Booking overlap detected for this vehicle.",
|
||||
"detected_at": "2026-08-01T09:00:00Z",
|
||||
"token": "fixture-token-must-not-leak",
|
||||
}
|
||||
]
|
||||
self._vehicle = {
|
||||
"public_ref": "MO-016",
|
||||
"make": "Volkswagen",
|
||||
"model": "ID.4",
|
||||
"model_year": 2024,
|
||||
"location": "Antwerp Depot",
|
||||
"operational_status": "available",
|
||||
"odometer_km": 18420,
|
||||
"next_service_km": 40000,
|
||||
"open_quality_issue_count": 1,
|
||||
"current_booking_ref": None,
|
||||
}
|
||||
self._answer = {
|
||||
"answer": "Report the damage and route the vehicle to maintenance.",
|
||||
"evidence_state": "grounded",
|
||||
"sources": [
|
||||
{
|
||||
"document_id": "damage-procedure",
|
||||
"title": "Damage handling procedure",
|
||||
"version": "1",
|
||||
"section": "Detection",
|
||||
"excerpt": "Inspect the vehicle for visible damage.",
|
||||
}
|
||||
],
|
||||
"provider": "demo",
|
||||
"correlation_id": "fixture-correlation-id",
|
||||
}
|
||||
|
||||
def transport(self) -> httpx.MockTransport:
|
||||
return httpx.MockTransport(self._handle)
|
||||
|
||||
def _handle(self, request: httpx.Request) -> httpx.Response:
|
||||
self.recorded_methods.append(request.method)
|
||||
self.recorded_service_tokens.append(request.headers.get("x-service-token", ""))
|
||||
self.recorded_client_ids.append(request.headers.get("x-client-id", ""))
|
||||
self.recorded_correlation_ids.append(request.headers.get("x-correlation-id", ""))
|
||||
if self.unreachable:
|
||||
raise httpx.ConnectError("fixture target is unreachable", request=request)
|
||||
if self.redirect_to is not None:
|
||||
return httpx.Response(302, headers={"location": self.redirect_to}, request=request)
|
||||
if self.unauthorized:
|
||||
return self._json(401, {"code": "UNAUTHORIZED_SERVICE"}, request)
|
||||
|
||||
parsed = urlparse(str(request.url))
|
||||
path = parsed.path.removeprefix("/api/v1/integrations/mcp")
|
||||
query = parse_qs(parsed.query)
|
||||
|
||||
if path == "/operations-summary" and request.method == "GET":
|
||||
return self._json(200, self._summary, request)
|
||||
if path == "/attention-vehicles" and request.method == "GET":
|
||||
limit = int(query.get("limit", ["20"])[0])
|
||||
return self._json(200, self._attention[:limit], request)
|
||||
if path.startswith("/vehicles/") and request.method == "GET":
|
||||
if self.vehicle_not_found:
|
||||
return self._json(404, {"code": "VEHICLE_NOT_FOUND"}, request)
|
||||
return self._json(200, self._vehicle, request, leaked_header=True)
|
||||
if path == "/search-knowledge" and request.method == "POST":
|
||||
body = json.loads(request.content or b"{}")
|
||||
self.recorded_locales.append(body.get("locale"))
|
||||
# MobilityOps echoes the correlation id it actually received.
|
||||
answer = dict(self._answer)
|
||||
answer["correlation_id"] = (
|
||||
request.headers.get("x-correlation-id") or answer["correlation_id"]
|
||||
)
|
||||
return self._json(200, answer, request)
|
||||
return self._json(404, {"code": "NOT_FOUND"}, request)
|
||||
|
||||
@staticmethod
|
||||
def _json(
|
||||
status: int, payload: object, request: httpx.Request, *, leaked_header: bool = False
|
||||
) -> httpx.Response:
|
||||
headers = {"content-type": "application/json"}
|
||||
if leaked_header:
|
||||
headers["x-fixture-token"] = "fixture-token-must-not-leak"
|
||||
return httpx.Response(
|
||||
status, headers=headers, content=json.dumps(payload).encode(), request=request
|
||||
)
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Read-only MCP connector for the MobilityOps (Fleet Ops) operations API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from packages.connector_kit import (
|
||||
DEFAULT_KNOWLEDGE_LOCALE,
|
||||
KNOWLEDGE_LOCALES,
|
||||
ConnectorContext,
|
||||
MobilityOpsClient,
|
||||
MobilityOpsError,
|
||||
ResourcePublication,
|
||||
ToolPublication,
|
||||
safe_snippet,
|
||||
)
|
||||
from packages.hub_policy import Governance, governed_tool
|
||||
|
||||
|
||||
class MobilityOpsConnector:
|
||||
"""Expose MobilityOps's four read-only operational tools.
|
||||
|
||||
Unlike Gitea, there is exactly one configured MobilityOps target per
|
||||
environment -- no per-project repository registry, every call is
|
||||
authorized purely by the caller's project/environment context via the
|
||||
governance layer, matching MobilityOps's own narrow read-only API surface
|
||||
(`docs/10-mcp-hub-integration.md` in the MobilityOps repository).
|
||||
"""
|
||||
|
||||
connector_type = "mobilityops"
|
||||
version = "1.0.0"
|
||||
#: Identifies readiness probes in MobilityOps's own service-request audit log.
|
||||
READINESS_CLIENT_ID = "itworx-mcp-hub:readiness"
|
||||
|
||||
def __init__(self, client: MobilityOpsClient, governance: Governance | None = None) -> None:
|
||||
self.client = client
|
||||
self.governance = governance
|
||||
|
||||
def _envelope(
|
||||
self,
|
||||
context: ConnectorContext,
|
||||
data: Any,
|
||||
*,
|
||||
source: str = "mobilityops",
|
||||
truncated: bool = False,
|
||||
warnings: tuple[str, ...] = (),
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"data": data,
|
||||
"meta": {
|
||||
"project": context.project_slug,
|
||||
"environment": context.environment,
|
||||
"source": source,
|
||||
"truncated": truncated,
|
||||
"nextCursor": None,
|
||||
"generatedAt": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
"warnings": list(warnings),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _client_id(context: ConnectorContext) -> str:
|
||||
return f"itworx-mcp-hub:{context.project_slug}:{context.client_id or 'anonymous'}"
|
||||
|
||||
@staticmethod
|
||||
def _optional_text(value: Any, limit: int) -> str | None:
|
||||
"""Pass through exactly what MobilityOps returned, or nothing at all.
|
||||
|
||||
Never substitutes a placeholder: an absent `provider`/`correlationId` is
|
||||
reported as `null` rather than invented.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text[:limit] if text else None
|
||||
|
||||
@governed_tool("mobilityops.operations.summary")
|
||||
def operations_summary(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
payload = self.client.operations_summary(
|
||||
self._client_id(context), correlation_id=context.correlation_id
|
||||
)
|
||||
raw_metrics = payload.get("metrics")
|
||||
metrics: dict[str, Any] = raw_metrics if isinstance(raw_metrics, dict) else {}
|
||||
data = {
|
||||
"tenant": safe_snippet(str(payload.get("tenant", "")), max_bytes=200),
|
||||
"metrics": {
|
||||
"available": int(metrics.get("available", 0)),
|
||||
"rented": int(metrics.get("rented", 0)),
|
||||
"cleaning": int(metrics.get("cleaning", 0)),
|
||||
"maintenance": int(metrics.get("maintenance", 0)),
|
||||
"blocked": int(metrics.get("blocked", 0)),
|
||||
"openQualityIssues": int(metrics.get("open_quality_issues", 0)),
|
||||
"pendingOrFailedWorkflows": int(metrics.get("pending_or_failed_workflows", 0)),
|
||||
},
|
||||
}
|
||||
return self._envelope(context, data)
|
||||
|
||||
@governed_tool("mobilityops.attention.list")
|
||||
def attention_list(
|
||||
self,
|
||||
context: ConnectorContext,
|
||||
*,
|
||||
minimum_severity: str = "medium",
|
||||
date: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
if minimum_severity not in {"low", "medium", "high"}:
|
||||
raise ValueError("minimum_severity must be one of low, medium, high")
|
||||
if not 1 <= limit <= 50:
|
||||
raise ValueError("limit must be between 1 and 50")
|
||||
items = self.client.attention_vehicles(
|
||||
self._client_id(context),
|
||||
minimum_severity=minimum_severity,
|
||||
date=date,
|
||||
limit=limit,
|
||||
correlation_id=context.correlation_id,
|
||||
)
|
||||
mapped = [
|
||||
{
|
||||
"vehicleRef": str(item.get("vehicle_ref", ""))[:64],
|
||||
"severity": str(item.get("severity", ""))[:32],
|
||||
"ruleType": str(item.get("rule_type", ""))[:120],
|
||||
"summary": safe_snippet(str(item.get("summary", "")), max_bytes=1000),
|
||||
"detectedAt": str(item.get("detected_at", ""))[:64],
|
||||
}
|
||||
for item in items
|
||||
]
|
||||
data = {"items": mapped, "nextCursor": None, "truncated": False}
|
||||
return self._envelope(context, data, truncated=False)
|
||||
|
||||
@governed_tool("mobilityops.vehicle.get")
|
||||
def vehicle_get(self, context: ConnectorContext, vehicle_ref: str) -> dict[str, Any]:
|
||||
payload = self.client.vehicle_details(
|
||||
self._client_id(context), vehicle_ref, correlation_id=context.correlation_id
|
||||
)
|
||||
data = {
|
||||
"publicRef": str(payload.get("public_ref", ""))[:64],
|
||||
"make": str(payload.get("make", ""))[:120],
|
||||
"model": str(payload.get("model", ""))[:120],
|
||||
"modelYear": int(payload.get("model_year", 0)),
|
||||
"location": str(payload.get("location", ""))[:200],
|
||||
"operationalStatus": str(payload.get("operational_status", ""))[:64],
|
||||
"odometerKm": int(payload.get("odometer_km", 0)),
|
||||
"nextServiceKm": int(payload.get("next_service_km", 0)),
|
||||
"openQualityIssueCount": int(payload.get("open_quality_issue_count", 0)),
|
||||
"currentBookingRef": (
|
||||
str(payload["current_booking_ref"])[:64]
|
||||
if payload.get("current_booking_ref")
|
||||
else None
|
||||
),
|
||||
}
|
||||
return self._envelope(context, data, source=f"mobilityops:{vehicle_ref}")
|
||||
|
||||
@governed_tool("mobilityops.knowledge.search")
|
||||
def knowledge_search(
|
||||
self,
|
||||
context: ConnectorContext,
|
||||
question: str,
|
||||
*,
|
||||
max_sources: int = 4,
|
||||
locale: str = DEFAULT_KNOWLEDGE_LOCALE,
|
||||
) -> dict[str, Any]:
|
||||
if not 3 <= len(question) <= 1000:
|
||||
raise ValueError("question is outside the connector budget")
|
||||
if not 1 <= max_sources <= 8:
|
||||
raise ValueError("max_sources must be between 1 and 8")
|
||||
if locale not in KNOWLEDGE_LOCALES:
|
||||
raise ValueError("locale must be one of nl-BE, en-GB, fr-BE")
|
||||
payload = self.client.search_knowledge(
|
||||
self._client_id(context),
|
||||
question,
|
||||
max_sources=max_sources,
|
||||
locale=locale,
|
||||
correlation_id=context.correlation_id,
|
||||
)
|
||||
raw_sources = payload.get("sources")
|
||||
sources: list[Any] = raw_sources if isinstance(raw_sources, list) else []
|
||||
data = {
|
||||
"answer": safe_snippet(str(payload.get("answer", "")), max_bytes=4000),
|
||||
"evidenceState": str(payload.get("evidence_state", "unavailable"))[:32],
|
||||
"provider": self._optional_text(payload.get("provider"), 120),
|
||||
"correlationId": self._optional_text(payload.get("correlation_id"), 128),
|
||||
"sources": [
|
||||
{
|
||||
"documentId": str(source.get("document_id", ""))[:200],
|
||||
"title": str(source.get("title", ""))[:300],
|
||||
"version": str(source.get("version", ""))[:120],
|
||||
"section": str(source.get("section", ""))[:300],
|
||||
"excerpt": safe_snippet(str(source.get("excerpt", "")), max_bytes=1500),
|
||||
}
|
||||
for source in sources
|
||||
if isinstance(source, dict)
|
||||
],
|
||||
}
|
||||
return self._envelope(context, data, source="mobilityops:knowledge")
|
||||
|
||||
def tools(self) -> tuple[ToolPublication, ...]:
|
||||
return MOBILITYOPS_TOOLS
|
||||
|
||||
def resources(self) -> tuple[ResourcePublication, ...]:
|
||||
return ()
|
||||
|
||||
def invoke(self, tool: str, context: ConnectorContext, **arguments: Any) -> dict[str, Any]:
|
||||
methods: dict[str, Callable[..., dict[str, Any]]] = {
|
||||
"mobilityops.operations.summary": self.operations_summary,
|
||||
"mobilityops.attention.list": self.attention_list,
|
||||
"mobilityops.vehicle.get": self.vehicle_get,
|
||||
"mobilityops.knowledge.search": self.knowledge_search,
|
||||
}
|
||||
method = methods.get(tool)
|
||||
if method is None:
|
||||
raise ValueError("unknown mobilityops tool")
|
||||
return method(context, **arguments)
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
"""Liveness only: this process answers, independent of the MobilityOps upstream."""
|
||||
return {"status": "healthy", "component": "mobilityops-connector", "readOnly": True}
|
||||
|
||||
#: Upstream faults that mean MobilityOps answered but this connector cannot use it.
|
||||
_DEGRADED_CODES = frozenset(
|
||||
{
|
||||
"MOBILITYOPS_ACCESS_DENIED",
|
||||
"MOBILITYOPS_REQUEST_INVALID",
|
||||
"MOBILITYOPS_INVALID_RESPONSE",
|
||||
"MOBILITYOPS_RESPONSE_TOO_LARGE",
|
||||
"MOBILITYOPS_REDIRECT_DENIED",
|
||||
"MOBILITYOPS_SECRET_UNAVAILABLE",
|
||||
"MOBILITYOPS_SECRET_INVALID",
|
||||
"MOBILITYOPS_SECRET_REFERENCE_INVALID",
|
||||
}
|
||||
)
|
||||
|
||||
def readiness(self) -> dict[str, Any]:
|
||||
"""Ready only when the real MobilityOps upstream actually answers.
|
||||
|
||||
Uses one short, bounded, authenticated upstream read. Only the connector's own
|
||||
stable error code is reported -- never a raw exception, response body, header or
|
||||
secret.
|
||||
"""
|
||||
snapshot: dict[str, Any] = {
|
||||
"component": "mobilityops-connector",
|
||||
"readOnly": True,
|
||||
"upstream": "mobilityops",
|
||||
}
|
||||
try:
|
||||
self.client.readiness_probe(self.READINESS_CLIENT_ID)
|
||||
except MobilityOpsError as exc:
|
||||
snapshot["status"] = "degraded" if exc.code in self._DEGRADED_CODES else "unavailable"
|
||||
snapshot["reason"] = exc.code
|
||||
return snapshot
|
||||
except Exception: # noqa: BLE001 - readiness must never surface a raw exception.
|
||||
snapshot["status"] = "unavailable"
|
||||
snapshot["reason"] = "MOBILITYOPS_READINESS_FAILED"
|
||||
return snapshot
|
||||
snapshot["status"] = "ready"
|
||||
return snapshot
|
||||
|
||||
|
||||
_EMPTY_INPUT = {"type": "object", "additionalProperties": False, "properties": {}}
|
||||
_ENVELOPE_OUTPUT = {"type": "object", "additionalProperties": False}
|
||||
|
||||
_OPERATIONS_SUMMARY_INPUT = _EMPTY_INPUT
|
||||
_ATTENTION_LIST_INPUT = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"minimum_severity": {"type": "string", "enum": ["low", "medium", "high"]},
|
||||
"date": {"type": ["string", "null"], "format": "date"},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 50},
|
||||
},
|
||||
}
|
||||
_VEHICLE_GET_INPUT = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {"vehicleRef": {"type": "string", "minLength": 1, "maxLength": 64}},
|
||||
"required": ["vehicleRef"],
|
||||
}
|
||||
_KNOWLEDGE_SEARCH_INPUT = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"question": {"type": "string", "minLength": 3, "maxLength": 1000},
|
||||
"max_sources": {"type": "integer", "minimum": 1, "maximum": 8},
|
||||
"locale": {
|
||||
"type": "string",
|
||||
"enum": list(KNOWLEDGE_LOCALES),
|
||||
"default": DEFAULT_KNOWLEDGE_LOCALE,
|
||||
},
|
||||
},
|
||||
"required": ["question"],
|
||||
}
|
||||
|
||||
_INPUT_SCHEMAS: dict[str, dict[str, Any]] = {
|
||||
"mobilityops.operations.summary": _OPERATIONS_SUMMARY_INPUT,
|
||||
"mobilityops.attention.list": _ATTENTION_LIST_INPUT,
|
||||
"mobilityops.vehicle.get": _VEHICLE_GET_INPUT,
|
||||
"mobilityops.knowledge.search": _KNOWLEDGE_SEARCH_INPUT,
|
||||
}
|
||||
|
||||
MOBILITYOPS_TOOLS = tuple(
|
||||
ToolPublication(
|
||||
canonical_name=name,
|
||||
version="1.0.0",
|
||||
connector_type="mobilityops",
|
||||
risk_class="R1",
|
||||
input_schema=_INPUT_SCHEMAS[name],
|
||||
output_schema=_ENVELOPE_OUTPUT,
|
||||
required_scopes=("project:{project}:read",),
|
||||
)
|
||||
for name in (
|
||||
"mobilityops.operations.summary",
|
||||
"mobilityops.attention.list",
|
||||
"mobilityops.vehicle.get",
|
||||
"mobilityops.knowledge.search",
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Read-only Unraid connector backed exclusively by the Docker read broker."""
|
||||
|
||||
from .fake import FakeBrokerClient
|
||||
from .server import UNRAID_RESOURCES, UNRAID_TOOLS, RegisteredContainer, UnraidConnector
|
||||
|
||||
__all__ = [
|
||||
"FakeBrokerClient",
|
||||
"RegisteredContainer",
|
||||
"UNRAID_RESOURCES",
|
||||
"UNRAID_TOOLS",
|
||||
"UnraidConnector",
|
||||
]
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Typed HTTP client for the internal Docker read broker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
import httpx
|
||||
|
||||
from packages.connector_kit import ConnectorContext
|
||||
from packages.hub_runtime import issue_broker_service_token
|
||||
|
||||
|
||||
class BrokerClientError(RuntimeError):
|
||||
def __init__(self, code: str, message: str, status: int = 502) -> None:
|
||||
self.code = code
|
||||
self.status = status
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class BrokerTokenProvider(Protocol):
|
||||
def get_token(self, context: ConnectorContext) -> str: ...
|
||||
|
||||
|
||||
class FileBrokerTokenProvider:
|
||||
def __init__(self, path: str = "/run/secrets/docker_broker_service_token") -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def get_token(self, context: ConnectorContext) -> str:
|
||||
try:
|
||||
secret = self.path.read_text(encoding="utf-8").strip()
|
||||
except OSError as exc:
|
||||
raise BrokerClientError(
|
||||
"BROKER_TOKEN_UNAVAILABLE", "broker token is unavailable", 503
|
||||
) from exc
|
||||
if len(secret) < 32:
|
||||
raise BrokerClientError("BROKER_TOKEN_INVALID", "broker signing key is invalid", 503)
|
||||
return issue_broker_service_token(
|
||||
secret,
|
||||
context.project_slug,
|
||||
context.environment,
|
||||
ttl_seconds=60,
|
||||
)
|
||||
|
||||
|
||||
class HttpBrokerClient:
|
||||
"""Only named broker operations exist; arbitrary paths are not accepted."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
token_provider: BrokerTokenProvider,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
if not base_url.startswith("http://") or any(char in base_url for char in "?#"):
|
||||
raise ValueError("broker base URL must be an internal HTTP origin without query data")
|
||||
self._client = httpx.Client(base_url=base_url.rstrip("/"), timeout=10, transport=transport)
|
||||
self._token_provider = token_provider
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def _get(
|
||||
self, context: ConnectorContext, path: str, params: Mapping[str, str | int] | None = None
|
||||
) -> dict[str, Any]:
|
||||
token = self._token_provider.get_token(context)
|
||||
try:
|
||||
response = self._client.get(
|
||||
path, params=params, headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||
raise BrokerClientError(
|
||||
"BROKER_UNAVAILABLE", "Docker read broker is unavailable", 503
|
||||
) from exc
|
||||
if response.status_code >= 400:
|
||||
try:
|
||||
detail = response.json()
|
||||
except ValueError:
|
||||
detail = {}
|
||||
raise BrokerClientError(
|
||||
str(detail.get("code", "BROKER_REQUEST_FAILED")),
|
||||
"Docker read broker denied the request",
|
||||
response.status_code,
|
||||
)
|
||||
try:
|
||||
value = response.json()
|
||||
except ValueError as exc:
|
||||
raise BrokerClientError(
|
||||
"BROKER_INVALID_RESPONSE", "Docker read broker returned invalid JSON"
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise BrokerClientError(
|
||||
"BROKER_INVALID_RESPONSE", "Docker read broker returned an unexpected response"
|
||||
)
|
||||
return value
|
||||
|
||||
def host(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
return self._get(context, "/v1/host")
|
||||
|
||||
def storage(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
return self._get(context, "/v1/storage")
|
||||
|
||||
def containers(self, context: ConnectorContext, state: str | None = None) -> dict[str, Any]:
|
||||
return self._get(context, "/v1/containers", {"state": state} if state else None)
|
||||
|
||||
def inspect(self, context: ConnectorContext, registered_id: str) -> dict[str, Any]:
|
||||
return self._get(context, f"/v1/containers/{registered_id}")
|
||||
|
||||
def health(self, context: ConnectorContext, registered_id: str) -> dict[str, Any]:
|
||||
return self._get(context, f"/v1/containers/{registered_id}/health")
|
||||
|
||||
def logs(
|
||||
self,
|
||||
context: ConnectorContext,
|
||||
registered_id: str,
|
||||
*,
|
||||
tail: int,
|
||||
since_seconds: int | None,
|
||||
contains: str | None,
|
||||
) -> dict[str, Any]:
|
||||
params: dict[str, str | int] = {"tail": tail}
|
||||
if since_seconds is not None:
|
||||
params["sinceSeconds"] = since_seconds
|
||||
if contains:
|
||||
params["contains"] = contains
|
||||
return self._get(context, f"/v1/containers/{registered_id}/logs", params)
|
||||
|
||||
def networks(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
return self._get(context, "/v1/networks")
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Deterministic broker client fake for Unraid connector tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from packages.connector_kit import ConnectorContext
|
||||
|
||||
|
||||
class FakeBrokerClient:
|
||||
def __init__(self, *, fail: bool = False) -> None:
|
||||
self.fail = fail
|
||||
self.calls: list[str] = []
|
||||
|
||||
def _check(self, operation: str) -> None:
|
||||
self.calls.append(operation)
|
||||
if self.fail:
|
||||
raise RuntimeError("broker unavailable")
|
||||
|
||||
def host(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
self._check("host")
|
||||
return {
|
||||
"hostRef": "unraid",
|
||||
"hostname": "unraid-test",
|
||||
"uptimeSeconds": 3600,
|
||||
"cpuPercent": 12.5,
|
||||
"memoryUsedBytes": 100,
|
||||
"memoryTotalBytes": 1000,
|
||||
"dockerVersion": "27.0.0",
|
||||
}
|
||||
|
||||
def storage(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
self._check("storage")
|
||||
return {
|
||||
"items": [
|
||||
{"name": "appdata", "driver": "local", "usageBytes": 1024, "capacityBytes": 4096}
|
||||
]
|
||||
}
|
||||
|
||||
def containers(self, context: ConnectorContext, state: str | None = None) -> dict[str, Any]:
|
||||
self._check("containers")
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"containerRef": "ludarium-web",
|
||||
"name": "ludarium-web",
|
||||
"image": "web:1",
|
||||
"state": state or "running",
|
||||
"health": "healthy",
|
||||
"startedAt": "2026-08-01T10:00:00Z",
|
||||
"restartCount": 2,
|
||||
"ports": [],
|
||||
"networks": ["hub-connectors"],
|
||||
"labels": {"com.docker.compose.project": "ludarium"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def inspect(self, context: ConnectorContext, registered_id: str) -> dict[str, Any]:
|
||||
self._check("inspect")
|
||||
return dict(self.containers(context)["items"][0]) | {"containerRef": registered_id}
|
||||
|
||||
def health(self, context: ConnectorContext, registered_id: str) -> dict[str, Any]:
|
||||
self._check("health")
|
||||
return {"status": "healthy", "running": True, "restartCount": 2}
|
||||
|
||||
def logs(
|
||||
self,
|
||||
context: ConnectorContext,
|
||||
registered_id: str,
|
||||
*,
|
||||
tail: int,
|
||||
since_seconds: int | None,
|
||||
contains: str | None,
|
||||
) -> dict[str, Any]:
|
||||
self._check("logs")
|
||||
return {
|
||||
"containerRef": registered_id,
|
||||
"lines": ["ready"],
|
||||
"truncated": False,
|
||||
"redactionsApplied": 0,
|
||||
}
|
||||
|
||||
def networks(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
self._check("networks")
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"name": "hub-connectors",
|
||||
"networkRef": "network-1",
|
||||
"driver": "bridge",
|
||||
"scope": "local",
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Project-scoped Unraid MCP connector; no Docker socket dependency."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from packages.connector_kit import (
|
||||
ConnectorContext,
|
||||
PageRequest,
|
||||
ResourcePublication,
|
||||
ToolPublication,
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
)
|
||||
from packages.hub_policy import Governance, governed_tool
|
||||
|
||||
from .client import BrokerClientError
|
||||
|
||||
|
||||
class RegisteredContainer:
|
||||
def __init__(self, registered_id: str, project_id: UUID, environment: str) -> None:
|
||||
self.registered_id = registered_id
|
||||
self.project_id = project_id
|
||||
self.environment = environment
|
||||
|
||||
|
||||
class UnraidConnector:
|
||||
connector_type = "unraid"
|
||||
version = "1.0.0"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Any,
|
||||
registrations: tuple[RegisteredContainer, ...],
|
||||
governance: Governance | None = None,
|
||||
) -> None:
|
||||
self.client = client
|
||||
self.governance = governance
|
||||
self._registrations = {item.registered_id: item for item in registrations}
|
||||
|
||||
def _resolve(self, context: ConnectorContext, registered_id: str) -> RegisteredContainer:
|
||||
item = self._registrations.get(registered_id)
|
||||
if (
|
||||
item is None
|
||||
or item.project_id != context.project_id
|
||||
or item.environment != context.environment
|
||||
):
|
||||
raise BrokerClientError(
|
||||
"UNRAID_CONTAINER_NOT_REGISTERED",
|
||||
"container is not registered for this project",
|
||||
403,
|
||||
)
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
def _envelope(
|
||||
context: ConnectorContext,
|
||||
data: Any,
|
||||
*,
|
||||
truncated: bool = False,
|
||||
next_cursor: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"data": data,
|
||||
"meta": {
|
||||
"project": context.project_slug,
|
||||
"environment": context.environment,
|
||||
"source": "unraid:docker-read-broker",
|
||||
"truncated": truncated,
|
||||
"nextCursor": next_cursor,
|
||||
"generatedAt": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
@governed_tool("unraid.host.status")
|
||||
def host_status(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
return self._envelope(context, {"hostRef": "unraid", **self.client.host(context)})
|
||||
|
||||
@governed_tool("unraid.storage.list")
|
||||
def storage_list(
|
||||
self, context: ConnectorContext, *, page: PageRequest | None = None
|
||||
) -> dict[str, Any]:
|
||||
return self._page_items(
|
||||
context, self.client.storage(context).get("items", []), page, "storage"
|
||||
)
|
||||
|
||||
@governed_tool("unraid.container.list")
|
||||
def container_list(
|
||||
self,
|
||||
context: ConnectorContext,
|
||||
*,
|
||||
state: str | None = None,
|
||||
page: PageRequest | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result = self.client.containers(context, state)
|
||||
return self._page_items(context, result.get("items", []), page, "containers")
|
||||
|
||||
@governed_tool("unraid.container.inspect")
|
||||
def container_inspect(self, context: ConnectorContext, container_ref: str) -> dict[str, Any]:
|
||||
self._resolve(context, container_ref)
|
||||
return self._envelope(context, self.client.inspect(context, container_ref))
|
||||
|
||||
@governed_tool("unraid.container.health")
|
||||
def container_health(self, context: ConnectorContext, container_ref: str) -> dict[str, Any]:
|
||||
self._resolve(context, container_ref)
|
||||
return self._envelope(context, self.client.health(context, container_ref))
|
||||
|
||||
@governed_tool("unraid.container.logs")
|
||||
def container_logs(
|
||||
self,
|
||||
context: ConnectorContext,
|
||||
container_ref: str,
|
||||
*,
|
||||
tail: int = 200,
|
||||
since_seconds: int | None = None,
|
||||
contains: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self._resolve(context, container_ref)
|
||||
if (
|
||||
not 1 <= tail <= 500
|
||||
or (since_seconds is not None and not 1 <= since_seconds <= 86400)
|
||||
or (contains is not None and len(contains) > 200)
|
||||
):
|
||||
raise ValueError("log request is outside the connector budget")
|
||||
return self._envelope(
|
||||
context,
|
||||
self.client.logs(
|
||||
context, container_ref, tail=tail, since_seconds=since_seconds, contains=contains
|
||||
),
|
||||
)
|
||||
|
||||
@governed_tool("unraid.network.list")
|
||||
def network_list(
|
||||
self, context: ConnectorContext, *, page: PageRequest | None = None
|
||||
) -> dict[str, Any]:
|
||||
return self._page_items(
|
||||
context, self.client.networks(context).get("items", []), page, "networks"
|
||||
)
|
||||
|
||||
@governed_tool("unraid.project.summary")
|
||||
def project_summary(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
expected = sorted(
|
||||
item.registered_id
|
||||
for item in self._registrations.values()
|
||||
if item.project_id == context.project_id and item.environment == context.environment
|
||||
)
|
||||
raw = self.client.containers(context, None).get("items", [])
|
||||
values = raw if isinstance(raw, list) else []
|
||||
containers = [
|
||||
item
|
||||
for item in values
|
||||
if isinstance(item, dict) and str(item.get("containerRef", "")) in expected
|
||||
]
|
||||
observed = {str(item.get("containerRef", "")) for item in containers}
|
||||
running = sum(item.get("state") == "running" for item in containers)
|
||||
healthy = sum(item.get("health") == "healthy" for item in containers)
|
||||
degraded = [
|
||||
str(item.get("containerRef", ""))
|
||||
for item in containers
|
||||
if item.get("state") != "running" or item.get("health") not in {"healthy", "none"}
|
||||
]
|
||||
missing = [item for item in expected if item not in observed]
|
||||
return self._envelope(
|
||||
context,
|
||||
{
|
||||
"status": "healthy" if not degraded and not missing else "degraded",
|
||||
"expectedContainers": len(expected),
|
||||
"observedContainers": len(containers),
|
||||
"runningContainers": running,
|
||||
"healthyContainers": healthy,
|
||||
"missingContainerRefs": missing,
|
||||
"degradedContainerRefs": degraded,
|
||||
"containers": containers,
|
||||
},
|
||||
)
|
||||
|
||||
@governed_tool("unraid.resource.get")
|
||||
def resource_get(self, context: ConnectorContext, container_ref: str) -> dict[str, Any]:
|
||||
return self.container_inspect(context, container_ref)
|
||||
|
||||
def _page_items(
|
||||
self, context: ConnectorContext, items: Any, page: PageRequest | None, kind: str
|
||||
) -> dict[str, Any]:
|
||||
request = page or PageRequest(limit=25)
|
||||
values = list(items) if isinstance(items, list) else []
|
||||
cursor = decode_cursor(request.cursor, str(context.project_id), kind, str(request.limit))
|
||||
selected = values[cursor.offset : cursor.offset + request.limit]
|
||||
next_cursor = None
|
||||
if cursor.offset + len(selected) < len(values):
|
||||
next_cursor = encode_cursor(
|
||||
cursor.offset + len(selected), str(context.project_id), kind, str(request.limit)
|
||||
)
|
||||
data = {"items": selected, "nextCursor": next_cursor, "truncated": next_cursor is not None}
|
||||
return self._envelope(
|
||||
context, data, truncated=next_cursor is not None, next_cursor=next_cursor
|
||||
)
|
||||
|
||||
def tools(self) -> tuple[ToolPublication, ...]:
|
||||
return UNRAID_TOOLS
|
||||
|
||||
def resources(self) -> tuple[ResourcePublication, ...]:
|
||||
return UNRAID_RESOURCES
|
||||
|
||||
def invoke(self, tool: str, context: ConnectorContext, **arguments: Any) -> dict[str, Any]:
|
||||
methods: dict[str, Callable[..., dict[str, Any]]] = {
|
||||
"unraid.host.status": self.host_status,
|
||||
"unraid.storage.list": self.storage_list,
|
||||
"unraid.container.list": self.container_list,
|
||||
"unraid.container.inspect": self.container_inspect,
|
||||
"unraid.container.health": self.container_health,
|
||||
"unraid.container.logs": self.container_logs,
|
||||
"unraid.network.list": self.network_list,
|
||||
"unraid.project.summary": self.project_summary,
|
||||
}
|
||||
method = methods.get(tool)
|
||||
if method is None:
|
||||
raise ValueError("unknown unraid tool")
|
||||
return method(context, **arguments)
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
return {"status": "healthy", "component": "unraid-connector", "broker": "required"}
|
||||
|
||||
def readiness(self) -> dict[str, Any]:
|
||||
return {"status": "ready", "component": "unraid-connector", "broker": "required"}
|
||||
|
||||
|
||||
_PAGE_INPUT = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
|
||||
"cursor": {"type": ["string", "null"], "maxLength": 500},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
_CONTAINER_INPUT = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {"containerRef": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,127}$"}},
|
||||
"required": ["containerRef"],
|
||||
}
|
||||
|
||||
UNRAID_TOOLS = tuple(
|
||||
ToolPublication(
|
||||
canonical_name=name,
|
||||
version="1.0.0",
|
||||
connector_type="unraid",
|
||||
risk_class="R1",
|
||||
input_schema=input_schema,
|
||||
output_schema={"type": "object"},
|
||||
required_scopes=("project:{project}:read",),
|
||||
)
|
||||
for name, input_schema in (
|
||||
(
|
||||
"unraid.host.status",
|
||||
{"type": "object", "additionalProperties": False, "properties": {}, "required": []},
|
||||
),
|
||||
(
|
||||
"unraid.project.summary",
|
||||
{"type": "object", "additionalProperties": False, "properties": {}, "required": []},
|
||||
),
|
||||
("unraid.storage.list", _PAGE_INPUT),
|
||||
("unraid.container.list", _PAGE_INPUT),
|
||||
("unraid.container.inspect", _CONTAINER_INPUT),
|
||||
("unraid.container.health", _CONTAINER_INPUT),
|
||||
("unraid.container.logs", _CONTAINER_INPUT),
|
||||
("unraid.network.list", _PAGE_INPUT),
|
||||
)
|
||||
)
|
||||
|
||||
UNRAID_RESOURCES = (
|
||||
ResourcePublication(
|
||||
uri_template="itworx-unraid://{project}/{environment}/container/{containerRef}",
|
||||
connector_type="unraid",
|
||||
risk_class="R1",
|
||||
mime_types=("application/json",),
|
||||
max_bytes=131072,
|
||||
required_scopes=("project:{project}:read",),
|
||||
),
|
||||
)
|
||||
Reference in new issue
Block a user