feat: harden gateway with policy engine, secure tools, and governance docs
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
"""Extended read-only MCP tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aegis_gitea_mcp.gitea_client import GiteaClient, GiteaError
|
||||
from aegis_gitea_mcp.response_limits import limit_items, limit_text
|
||||
from aegis_gitea_mcp.tools.arguments import (
|
||||
CommitDiffArgs,
|
||||
CompareRefsArgs,
|
||||
IssueArgs,
|
||||
ListCommitsArgs,
|
||||
ListIssuesArgs,
|
||||
ListLabelsArgs,
|
||||
ListPullRequestsArgs,
|
||||
ListReleasesArgs,
|
||||
ListTagsArgs,
|
||||
PullRequestArgs,
|
||||
SearchCodeArgs,
|
||||
)
|
||||
|
||||
|
||||
async def search_code_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Search repository code and return bounded result snippets."""
|
||||
parsed = SearchCodeArgs.model_validate(arguments)
|
||||
try:
|
||||
raw = await gitea.search_code(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
parsed.query,
|
||||
ref=parsed.ref,
|
||||
page=parsed.page,
|
||||
limit=parsed.limit,
|
||||
)
|
||||
hits_raw = raw.get("data", raw.get("hits", [])) if isinstance(raw, dict) else []
|
||||
if not isinstance(hits_raw, list):
|
||||
hits_raw = []
|
||||
|
||||
normalized_hits = []
|
||||
for item in hits_raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
snippet = str(item.get("content", item.get("snippet", "")))
|
||||
normalized_hits.append(
|
||||
{
|
||||
"path": item.get("filename", item.get("path", "")),
|
||||
"sha": item.get("sha", ""),
|
||||
"ref": parsed.ref,
|
||||
"snippet": limit_text(snippet),
|
||||
"score": item.get("score", 0),
|
||||
}
|
||||
)
|
||||
|
||||
bounded, omitted = limit_items(normalized_hits, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"query": parsed.query,
|
||||
"ref": parsed.ref,
|
||||
"results": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to search code: {exc}") from exc
|
||||
|
||||
|
||||
async def list_commits_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List commits for a repository reference."""
|
||||
parsed = ListCommitsArgs.model_validate(arguments)
|
||||
try:
|
||||
commits = await gitea.list_commits(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
ref=parsed.ref,
|
||||
page=parsed.page,
|
||||
limit=parsed.limit,
|
||||
)
|
||||
normalized = [
|
||||
{
|
||||
"sha": commit.get("sha", ""),
|
||||
"message": limit_text(str(commit.get("commit", {}).get("message", ""))),
|
||||
"author": commit.get("author", {}).get("login", ""),
|
||||
"created": commit.get("commit", {}).get("author", {}).get("date", ""),
|
||||
"url": commit.get("html_url", ""),
|
||||
}
|
||||
for commit in commits
|
||||
if isinstance(commit, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"ref": parsed.ref,
|
||||
"commits": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list commits: {exc}") from exc
|
||||
|
||||
|
||||
async def get_commit_diff_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return commit-level file diff metadata."""
|
||||
parsed = CommitDiffArgs.model_validate(arguments)
|
||||
try:
|
||||
commit = await gitea.get_commit_diff(parsed.owner, parsed.repo, parsed.sha)
|
||||
files = commit.get("files", []) if isinstance(commit, dict) else []
|
||||
normalized_files = []
|
||||
if isinstance(files, list):
|
||||
for item in files:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized_files.append(
|
||||
{
|
||||
"filename": item.get("filename", ""),
|
||||
"status": item.get("status", ""),
|
||||
"additions": item.get("additions", 0),
|
||||
"deletions": item.get("deletions", 0),
|
||||
"changes": item.get("changes", 0),
|
||||
"patch": limit_text(str(item.get("patch", ""))),
|
||||
}
|
||||
)
|
||||
bounded, omitted = limit_items(normalized_files)
|
||||
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"sha": parsed.sha,
|
||||
"message": limit_text(
|
||||
str(commit.get("message", commit.get("commit", {}).get("message", "")))
|
||||
),
|
||||
"files": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get commit diff: {exc}") from exc
|
||||
|
||||
|
||||
async def compare_refs_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Compare two refs and return bounded commit/file changes."""
|
||||
parsed = CompareRefsArgs.model_validate(arguments)
|
||||
try:
|
||||
comparison = await gitea.compare_refs(parsed.owner, parsed.repo, parsed.base, parsed.head)
|
||||
commits_raw = comparison.get("commits", []) if isinstance(comparison, dict) else []
|
||||
files_raw = comparison.get("files", []) if isinstance(comparison, dict) else []
|
||||
|
||||
commits = [
|
||||
{
|
||||
"sha": commit.get("sha", ""),
|
||||
"message": limit_text(str(commit.get("commit", {}).get("message", ""))),
|
||||
}
|
||||
for commit in commits_raw
|
||||
if isinstance(commit, dict)
|
||||
]
|
||||
commit_items, commit_omitted = limit_items(commits)
|
||||
|
||||
files = [
|
||||
{
|
||||
"filename": item.get("filename", ""),
|
||||
"status": item.get("status", ""),
|
||||
"additions": item.get("additions", 0),
|
||||
"deletions": item.get("deletions", 0),
|
||||
}
|
||||
for item in files_raw
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
file_items, file_omitted = limit_items(files)
|
||||
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"base": parsed.base,
|
||||
"head": parsed.head,
|
||||
"commits": commit_items,
|
||||
"files": file_items,
|
||||
"commit_count": len(commit_items),
|
||||
"file_count": len(file_items),
|
||||
"omitted_commits": commit_omitted,
|
||||
"omitted_files": file_omitted,
|
||||
}
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to compare refs: {exc}") from exc
|
||||
|
||||
|
||||
async def list_issues_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List issues for repository."""
|
||||
parsed = ListIssuesArgs.model_validate(arguments)
|
||||
try:
|
||||
issues = await gitea.list_issues(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
state=parsed.state,
|
||||
page=parsed.page,
|
||||
limit=parsed.limit,
|
||||
labels=parsed.labels,
|
||||
)
|
||||
normalized = [
|
||||
{
|
||||
"number": issue.get("number", 0),
|
||||
"title": limit_text(str(issue.get("title", ""))),
|
||||
"state": issue.get("state", ""),
|
||||
"author": issue.get("user", {}).get("login", ""),
|
||||
"labels": [label.get("name", "") for label in issue.get("labels", [])],
|
||||
"created_at": issue.get("created_at", ""),
|
||||
"updated_at": issue.get("updated_at", ""),
|
||||
"url": issue.get("html_url", ""),
|
||||
}
|
||||
for issue in issues
|
||||
if isinstance(issue, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"state": parsed.state,
|
||||
"issues": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list issues: {exc}") from exc
|
||||
|
||||
|
||||
async def get_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get issue details."""
|
||||
parsed = IssueArgs.model_validate(arguments)
|
||||
try:
|
||||
issue = await gitea.get_issue(parsed.owner, parsed.repo, parsed.issue_number)
|
||||
return {
|
||||
"number": issue.get("number", 0),
|
||||
"title": limit_text(str(issue.get("title", ""))),
|
||||
"body": limit_text(str(issue.get("body", ""))),
|
||||
"state": issue.get("state", ""),
|
||||
"author": issue.get("user", {}).get("login", ""),
|
||||
"labels": [label.get("name", "") for label in issue.get("labels", [])],
|
||||
"assignees": [assignee.get("login", "") for assignee in issue.get("assignees", [])],
|
||||
"created_at": issue.get("created_at", ""),
|
||||
"updated_at": issue.get("updated_at", ""),
|
||||
"url": issue.get("html_url", ""),
|
||||
}
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get issue: {exc}") from exc
|
||||
|
||||
|
||||
async def list_pull_requests_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List pull requests."""
|
||||
parsed = ListPullRequestsArgs.model_validate(arguments)
|
||||
try:
|
||||
pull_requests = await gitea.list_pull_requests(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
state=parsed.state,
|
||||
page=parsed.page,
|
||||
limit=parsed.limit,
|
||||
)
|
||||
normalized = [
|
||||
{
|
||||
"number": pull.get("number", 0),
|
||||
"title": limit_text(str(pull.get("title", ""))),
|
||||
"state": pull.get("state", ""),
|
||||
"author": pull.get("user", {}).get("login", ""),
|
||||
"draft": pull.get("draft", False),
|
||||
"mergeable": pull.get("mergeable", False),
|
||||
"created_at": pull.get("created_at", ""),
|
||||
"updated_at": pull.get("updated_at", ""),
|
||||
"url": pull.get("html_url", ""),
|
||||
}
|
||||
for pull in pull_requests
|
||||
if isinstance(pull, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"state": parsed.state,
|
||||
"pull_requests": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list pull requests: {exc}") from exc
|
||||
|
||||
|
||||
async def get_pull_request_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get pull request details."""
|
||||
parsed = PullRequestArgs.model_validate(arguments)
|
||||
try:
|
||||
pull = await gitea.get_pull_request(parsed.owner, parsed.repo, parsed.pull_number)
|
||||
return {
|
||||
"number": pull.get("number", 0),
|
||||
"title": limit_text(str(pull.get("title", ""))),
|
||||
"body": limit_text(str(pull.get("body", ""))),
|
||||
"state": pull.get("state", ""),
|
||||
"draft": pull.get("draft", False),
|
||||
"mergeable": pull.get("mergeable", False),
|
||||
"author": pull.get("user", {}).get("login", ""),
|
||||
"base": pull.get("base", {}).get("ref", ""),
|
||||
"head": pull.get("head", {}).get("ref", ""),
|
||||
"created_at": pull.get("created_at", ""),
|
||||
"updated_at": pull.get("updated_at", ""),
|
||||
"url": pull.get("html_url", ""),
|
||||
}
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get pull request: {exc}") from exc
|
||||
|
||||
|
||||
async def list_labels_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List labels configured on repository."""
|
||||
parsed = ListLabelsArgs.model_validate(arguments)
|
||||
try:
|
||||
labels = await gitea.list_labels(
|
||||
parsed.owner, parsed.repo, page=parsed.page, limit=parsed.limit
|
||||
)
|
||||
normalized = [
|
||||
{
|
||||
"id": label.get("id", 0),
|
||||
"name": label.get("name", ""),
|
||||
"color": label.get("color", ""),
|
||||
"description": limit_text(str(label.get("description", ""))),
|
||||
}
|
||||
for label in labels
|
||||
if isinstance(label, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"labels": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list labels: {exc}") from exc
|
||||
|
||||
|
||||
async def list_tags_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List repository tags."""
|
||||
parsed = ListTagsArgs.model_validate(arguments)
|
||||
try:
|
||||
tags = await gitea.list_tags(
|
||||
parsed.owner, parsed.repo, page=parsed.page, limit=parsed.limit
|
||||
)
|
||||
normalized = [
|
||||
{
|
||||
"name": tag.get("name", ""),
|
||||
"commit": tag.get("commit", {}).get("sha", ""),
|
||||
"zipball_url": tag.get("zipball_url", ""),
|
||||
"tarball_url": tag.get("tarball_url", ""),
|
||||
}
|
||||
for tag in tags
|
||||
if isinstance(tag, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"tags": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list tags: {exc}") from exc
|
||||
|
||||
|
||||
async def list_releases_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List repository releases."""
|
||||
parsed = ListReleasesArgs.model_validate(arguments)
|
||||
try:
|
||||
releases = await gitea.list_releases(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
page=parsed.page,
|
||||
limit=parsed.limit,
|
||||
)
|
||||
normalized = [
|
||||
{
|
||||
"id": release.get("id", 0),
|
||||
"tag_name": release.get("tag_name", ""),
|
||||
"name": limit_text(str(release.get("name", ""))),
|
||||
"draft": release.get("draft", False),
|
||||
"prerelease": release.get("prerelease", False),
|
||||
"body": limit_text(str(release.get("body", ""))),
|
||||
"created_at": release.get("created_at", ""),
|
||||
"published_at": release.get("published_at", ""),
|
||||
"url": release.get("html_url", ""),
|
||||
}
|
||||
for release in releases
|
||||
if isinstance(release, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"releases": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list releases: {exc}") from exc
|
||||
Reference in New Issue
Block a user