refactor: extract transport-agnostic core and shared tool registry
Introduce aegis_gitea_mcp.registry as the single name->handler source of truth consumed by every transport adapter, moving TOOL_HANDLERS out of the FastAPI server module. Add aegis_gitea_mcp.errors.ToolError so core handlers no longer import fastapi.HTTPException; raw_tools now raises ToolError and the HTTP adapter maps it back to HTTPException, preserving status codes and audit behavior. Add a subprocess boundary test asserting the core imports without pulling in fastapi/uvicorn/starlette. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
"""Transport-agnostic error types raised by the core.
|
||||||
|
|
||||||
|
Core tool handlers and the authorization layer must not depend on the web stack
|
||||||
|
(FastAPI). They raise :class:`ToolError` carrying an advisory HTTP status code;
|
||||||
|
each transport adapter maps it to its own wire format (the HTTP adapter to
|
||||||
|
``fastapi.HTTPException``, the stdio adapter to an MCP error). This keeps the
|
||||||
|
core importable without FastAPI installed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class ToolError(Exception):
|
||||||
|
"""Error raised by a core tool handler or the authorization layer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Human-readable, non-sensitive error detail.
|
||||||
|
status_code: Advisory HTTP status (e.g. 403 for denied). Adapters map
|
||||||
|
this to their transport; the stdio adapter only uses the message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, status_code: int = 400) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.detail = message
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Shared, transport-agnostic tool registry.
|
||||||
|
|
||||||
|
This module is the single source of truth that maps each MCP tool name to its
|
||||||
|
async handler. Both transport adapters consume it:
|
||||||
|
|
||||||
|
* the HTTP/OAuth server (``server.py``), and
|
||||||
|
* the local stdio adapter (``stdio_app.py``).
|
||||||
|
|
||||||
|
Tool *definitions* (name, description, JSON schema, read/write flag) live in
|
||||||
|
``mcp_protocol.AVAILABLE_TOOLS``; this module binds those names to callables and
|
||||||
|
exposes lookup helpers so neither adapter duplicates the tool list. It imports
|
||||||
|
only core modules and never the web stack, keeping the core importable without
|
||||||
|
FastAPI installed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.gitea_client import GiteaClient
|
||||||
|
from aegis_gitea_mcp.mcp_protocol import (
|
||||||
|
AVAILABLE_TOOLS,
|
||||||
|
MCPTool,
|
||||||
|
get_tool_by_name,
|
||||||
|
)
|
||||||
|
from aegis_gitea_mcp.tools.raw_tools import raw_api_request_tool
|
||||||
|
from aegis_gitea_mcp.tools.read_tools import (
|
||||||
|
compare_refs_tool,
|
||||||
|
get_branch_tool,
|
||||||
|
get_commit_diff_tool,
|
||||||
|
get_commit_status_tool,
|
||||||
|
get_issue_tool,
|
||||||
|
get_latest_release_tool,
|
||||||
|
get_pull_request_tool,
|
||||||
|
get_release_tool,
|
||||||
|
get_repo_languages_tool,
|
||||||
|
list_branches_tool,
|
||||||
|
list_commits_tool,
|
||||||
|
list_issue_comments_tool,
|
||||||
|
list_issues_tool,
|
||||||
|
list_labels_tool,
|
||||||
|
list_milestones_tool,
|
||||||
|
list_org_repositories_tool,
|
||||||
|
list_organizations_tool,
|
||||||
|
list_pull_request_commits_tool,
|
||||||
|
list_pull_request_files_tool,
|
||||||
|
list_pull_requests_tool,
|
||||||
|
list_releases_tool,
|
||||||
|
list_repo_topics_tool,
|
||||||
|
list_tags_tool,
|
||||||
|
search_code_tool,
|
||||||
|
)
|
||||||
|
from aegis_gitea_mcp.tools.repository import (
|
||||||
|
get_file_contents_tool,
|
||||||
|
get_file_tree_tool,
|
||||||
|
get_repository_info_tool,
|
||||||
|
list_repositories_tool,
|
||||||
|
)
|
||||||
|
from aegis_gitea_mcp.tools.write_tools import (
|
||||||
|
add_labels_tool,
|
||||||
|
assign_issue_tool,
|
||||||
|
create_branch_tool,
|
||||||
|
create_issue_comment_tool,
|
||||||
|
create_issue_tool,
|
||||||
|
create_label_tool,
|
||||||
|
create_milestone_tool,
|
||||||
|
create_pr_comment_tool,
|
||||||
|
create_pull_request_tool,
|
||||||
|
create_release_tool,
|
||||||
|
edit_issue_comment_tool,
|
||||||
|
edit_release_tool,
|
||||||
|
remove_labels_tool,
|
||||||
|
update_issue_tool,
|
||||||
|
update_label_tool,
|
||||||
|
)
|
||||||
|
|
||||||
|
ToolHandler = Callable[[GiteaClient, dict[str, Any]], Awaitable[dict[str, Any]]]
|
||||||
|
|
||||||
|
TOOL_HANDLERS: dict[str, ToolHandler] = {
|
||||||
|
# Baseline read tools
|
||||||
|
"list_repositories": list_repositories_tool,
|
||||||
|
"get_repository_info": get_repository_info_tool,
|
||||||
|
"get_file_tree": get_file_tree_tool,
|
||||||
|
"get_file_contents": get_file_contents_tool,
|
||||||
|
# Expanded read tools
|
||||||
|
"search_code": search_code_tool,
|
||||||
|
"list_commits": list_commits_tool,
|
||||||
|
"get_commit_diff": get_commit_diff_tool,
|
||||||
|
"compare_refs": compare_refs_tool,
|
||||||
|
"list_issues": list_issues_tool,
|
||||||
|
"get_issue": get_issue_tool,
|
||||||
|
"list_pull_requests": list_pull_requests_tool,
|
||||||
|
"get_pull_request": get_pull_request_tool,
|
||||||
|
"list_labels": list_labels_tool,
|
||||||
|
"list_tags": list_tags_tool,
|
||||||
|
"list_releases": list_releases_tool,
|
||||||
|
"list_pull_request_files": list_pull_request_files_tool,
|
||||||
|
"list_pull_request_commits": list_pull_request_commits_tool,
|
||||||
|
"list_issue_comments": list_issue_comments_tool,
|
||||||
|
"list_branches": list_branches_tool,
|
||||||
|
"get_branch": get_branch_tool,
|
||||||
|
"get_release": get_release_tool,
|
||||||
|
"get_latest_release": get_latest_release_tool,
|
||||||
|
"list_milestones": list_milestones_tool,
|
||||||
|
"get_commit_status": get_commit_status_tool,
|
||||||
|
"list_org_repositories": list_org_repositories_tool,
|
||||||
|
"list_organizations": list_organizations_tool,
|
||||||
|
"get_repo_languages": get_repo_languages_tool,
|
||||||
|
"list_repo_topics": list_repo_topics_tool,
|
||||||
|
# Write-mode tools
|
||||||
|
"create_issue": create_issue_tool,
|
||||||
|
"update_issue": update_issue_tool,
|
||||||
|
"create_issue_comment": create_issue_comment_tool,
|
||||||
|
"create_pr_comment": create_pr_comment_tool,
|
||||||
|
"add_labels": add_labels_tool,
|
||||||
|
"assign_issue": assign_issue_tool,
|
||||||
|
"create_label": create_label_tool,
|
||||||
|
"update_label": update_label_tool,
|
||||||
|
"remove_labels": remove_labels_tool,
|
||||||
|
"create_pull_request": create_pull_request_tool,
|
||||||
|
"create_release": create_release_tool,
|
||||||
|
"edit_release": edit_release_tool,
|
||||||
|
"create_branch": create_branch_tool,
|
||||||
|
"create_milestone": create_milestone_tool,
|
||||||
|
"edit_issue_comment": edit_issue_comment_tool,
|
||||||
|
# Generic raw API dispatch (escape hatch). Registered as a read tool so GETs
|
||||||
|
# work without write-mode; the handler authorizes writes per-method itself.
|
||||||
|
"gitea_request": raw_api_request_tool,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_handler(tool_name: str) -> ToolHandler | None:
|
||||||
|
"""Return the async handler bound to a tool name, or None if unknown."""
|
||||||
|
return TOOL_HANDLERS.get(tool_name)
|
||||||
|
|
||||||
|
|
||||||
|
def list_tool_definitions() -> list[MCPTool]:
|
||||||
|
"""Return all registered tool definitions (name, schema, read/write flag)."""
|
||||||
|
return list(AVAILABLE_TOOLS)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AVAILABLE_TOOLS",
|
||||||
|
"MCPTool",
|
||||||
|
"ToolHandler",
|
||||||
|
"TOOL_HANDLERS",
|
||||||
|
"get_tool_by_name",
|
||||||
|
"get_tool_handler",
|
||||||
|
"list_tool_definitions",
|
||||||
|
]
|
||||||
@@ -22,6 +22,7 @@ from aegis_gitea_mcp.audit import get_audit_logger
|
|||||||
from aegis_gitea_mcp.automation import AutomationError, AutomationManager
|
from aegis_gitea_mcp.automation import AutomationError, AutomationManager
|
||||||
from aegis_gitea_mcp.cache import BoundedTTLCache
|
from aegis_gitea_mcp.cache import BoundedTTLCache
|
||||||
from aegis_gitea_mcp.config import get_settings
|
from aegis_gitea_mcp.config import get_settings
|
||||||
|
from aegis_gitea_mcp.errors import ToolError
|
||||||
from aegis_gitea_mcp.gitea_client import (
|
from aegis_gitea_mcp.gitea_client import (
|
||||||
GiteaAuthenticationError,
|
GiteaAuthenticationError,
|
||||||
GiteaAuthorizationError,
|
GiteaAuthorizationError,
|
||||||
@@ -48,6 +49,7 @@ from aegis_gitea_mcp.oauth_flow import (
|
|||||||
from aegis_gitea_mcp.observability import get_metrics_registry, monotonic_seconds
|
from aegis_gitea_mcp.observability import get_metrics_registry, monotonic_seconds
|
||||||
from aegis_gitea_mcp.policy import PolicyError, get_policy_engine
|
from aegis_gitea_mcp.policy import PolicyError, get_policy_engine
|
||||||
from aegis_gitea_mcp.rate_limit import get_rate_limiter
|
from aegis_gitea_mcp.rate_limit import get_rate_limiter
|
||||||
|
from aegis_gitea_mcp.registry import TOOL_HANDLERS
|
||||||
from aegis_gitea_mcp.request_context import (
|
from aegis_gitea_mcp.request_context import (
|
||||||
clear_gitea_auth_context,
|
clear_gitea_auth_context,
|
||||||
get_gitea_user_login,
|
get_gitea_user_login,
|
||||||
@@ -60,56 +62,6 @@ from aegis_gitea_mcp.request_context import (
|
|||||||
)
|
)
|
||||||
from aegis_gitea_mcp.security import sanitize_data
|
from aegis_gitea_mcp.security import sanitize_data
|
||||||
from aegis_gitea_mcp.tools.arguments import extract_repository, extract_target_path
|
from aegis_gitea_mcp.tools.arguments import extract_repository, extract_target_path
|
||||||
from aegis_gitea_mcp.tools.raw_tools import raw_api_request_tool
|
|
||||||
from aegis_gitea_mcp.tools.read_tools import (
|
|
||||||
compare_refs_tool,
|
|
||||||
get_branch_tool,
|
|
||||||
get_commit_diff_tool,
|
|
||||||
get_commit_status_tool,
|
|
||||||
get_issue_tool,
|
|
||||||
get_latest_release_tool,
|
|
||||||
get_pull_request_tool,
|
|
||||||
get_release_tool,
|
|
||||||
get_repo_languages_tool,
|
|
||||||
list_branches_tool,
|
|
||||||
list_commits_tool,
|
|
||||||
list_issue_comments_tool,
|
|
||||||
list_issues_tool,
|
|
||||||
list_labels_tool,
|
|
||||||
list_milestones_tool,
|
|
||||||
list_org_repositories_tool,
|
|
||||||
list_organizations_tool,
|
|
||||||
list_pull_request_commits_tool,
|
|
||||||
list_pull_request_files_tool,
|
|
||||||
list_pull_requests_tool,
|
|
||||||
list_releases_tool,
|
|
||||||
list_repo_topics_tool,
|
|
||||||
list_tags_tool,
|
|
||||||
search_code_tool,
|
|
||||||
)
|
|
||||||
from aegis_gitea_mcp.tools.repository import (
|
|
||||||
get_file_contents_tool,
|
|
||||||
get_file_tree_tool,
|
|
||||||
get_repository_info_tool,
|
|
||||||
list_repositories_tool,
|
|
||||||
)
|
|
||||||
from aegis_gitea_mcp.tools.write_tools import (
|
|
||||||
add_labels_tool,
|
|
||||||
assign_issue_tool,
|
|
||||||
create_branch_tool,
|
|
||||||
create_issue_comment_tool,
|
|
||||||
create_issue_tool,
|
|
||||||
create_label_tool,
|
|
||||||
create_milestone_tool,
|
|
||||||
create_pr_comment_tool,
|
|
||||||
create_pull_request_tool,
|
|
||||||
create_release_tool,
|
|
||||||
edit_issue_comment_tool,
|
|
||||||
edit_release_tool,
|
|
||||||
remove_labels_tool,
|
|
||||||
update_issue_tool,
|
|
||||||
update_label_tool,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -372,61 +324,6 @@ class AutomationJobRequest(BaseModel):
|
|||||||
finding_body: str | None = Field(default=None, max_length=10_000)
|
finding_body: str | None = Field(default=None, max_length=10_000)
|
||||||
|
|
||||||
|
|
||||||
ToolHandler = Callable[[GiteaClient, dict[str, Any]], Awaitable[dict[str, Any]]]
|
|
||||||
|
|
||||||
TOOL_HANDLERS: dict[str, ToolHandler] = {
|
|
||||||
# Baseline read tools
|
|
||||||
"list_repositories": list_repositories_tool,
|
|
||||||
"get_repository_info": get_repository_info_tool,
|
|
||||||
"get_file_tree": get_file_tree_tool,
|
|
||||||
"get_file_contents": get_file_contents_tool,
|
|
||||||
# Expanded read tools
|
|
||||||
"search_code": search_code_tool,
|
|
||||||
"list_commits": list_commits_tool,
|
|
||||||
"get_commit_diff": get_commit_diff_tool,
|
|
||||||
"compare_refs": compare_refs_tool,
|
|
||||||
"list_issues": list_issues_tool,
|
|
||||||
"get_issue": get_issue_tool,
|
|
||||||
"list_pull_requests": list_pull_requests_tool,
|
|
||||||
"get_pull_request": get_pull_request_tool,
|
|
||||||
"list_labels": list_labels_tool,
|
|
||||||
"list_tags": list_tags_tool,
|
|
||||||
"list_releases": list_releases_tool,
|
|
||||||
"list_pull_request_files": list_pull_request_files_tool,
|
|
||||||
"list_pull_request_commits": list_pull_request_commits_tool,
|
|
||||||
"list_issue_comments": list_issue_comments_tool,
|
|
||||||
"list_branches": list_branches_tool,
|
|
||||||
"get_branch": get_branch_tool,
|
|
||||||
"get_release": get_release_tool,
|
|
||||||
"get_latest_release": get_latest_release_tool,
|
|
||||||
"list_milestones": list_milestones_tool,
|
|
||||||
"get_commit_status": get_commit_status_tool,
|
|
||||||
"list_org_repositories": list_org_repositories_tool,
|
|
||||||
"list_organizations": list_organizations_tool,
|
|
||||||
"get_repo_languages": get_repo_languages_tool,
|
|
||||||
"list_repo_topics": list_repo_topics_tool,
|
|
||||||
# Write-mode tools
|
|
||||||
"create_issue": create_issue_tool,
|
|
||||||
"update_issue": update_issue_tool,
|
|
||||||
"create_issue_comment": create_issue_comment_tool,
|
|
||||||
"create_pr_comment": create_pr_comment_tool,
|
|
||||||
"add_labels": add_labels_tool,
|
|
||||||
"assign_issue": assign_issue_tool,
|
|
||||||
"create_label": create_label_tool,
|
|
||||||
"update_label": update_label_tool,
|
|
||||||
"remove_labels": remove_labels_tool,
|
|
||||||
"create_pull_request": create_pull_request_tool,
|
|
||||||
"create_release": create_release_tool,
|
|
||||||
"edit_release": edit_release_tool,
|
|
||||||
"create_branch": create_branch_tool,
|
|
||||||
"create_milestone": create_milestone_tool,
|
|
||||||
"edit_issue_comment": edit_issue_comment_tool,
|
|
||||||
# Generic raw API dispatch (escape hatch). Registered as a read tool so GETs
|
|
||||||
# work without write-mode; the handler authorizes writes per-method itself.
|
|
||||||
"gitea_request": raw_api_request_tool,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _oauth_metadata_url(request: Request) -> str:
|
def _oauth_metadata_url(request: Request) -> str:
|
||||||
"""Build absolute metadata URL for OAuth challenge responses."""
|
"""Build absolute metadata URL for OAuth challenge responses."""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -1280,7 +1177,13 @@ async def _execute_tool_call(
|
|||||||
api_token = settings.gitea_token.strip() if settings.gitea_token.strip() else user_token
|
api_token = settings.gitea_token.strip() if settings.gitea_token.strip() else user_token
|
||||||
|
|
||||||
async with GiteaClient(token=api_token) as gitea:
|
async with GiteaClient(token=api_token) as gitea:
|
||||||
result = await handler(gitea, arguments)
|
try:
|
||||||
|
result = await handler(gitea, arguments)
|
||||||
|
except ToolError as exc:
|
||||||
|
# Core handlers raise the transport-agnostic ToolError; the HTTP
|
||||||
|
# adapter maps it to the matching HTTPException so existing
|
||||||
|
# status codes and audit/error envelopes are preserved.
|
||||||
|
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||||
|
|
||||||
if settings.secret_detection_mode != "off":
|
if settings.secret_detection_mode != "off":
|
||||||
# Security decision: sanitize outbound payloads to prevent accidental secret exfiltration.
|
# Security decision: sanitize outbound payloads to prevent accidental secret exfiltration.
|
||||||
|
|||||||
@@ -22,10 +22,9 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
from aegis_gitea_mcp.audit import get_audit_logger
|
from aegis_gitea_mcp.audit import get_audit_logger
|
||||||
from aegis_gitea_mcp.config import get_settings
|
from aegis_gitea_mcp.config import get_settings
|
||||||
|
from aegis_gitea_mcp.errors import ToolError
|
||||||
from aegis_gitea_mcp.gitea_client import (
|
from aegis_gitea_mcp.gitea_client import (
|
||||||
GiteaAuthenticationError,
|
GiteaAuthenticationError,
|
||||||
GiteaAuthorizationError,
|
GiteaAuthorizationError,
|
||||||
@@ -68,9 +67,9 @@ async def raw_api_request_tool(gitea: GiteaClient, arguments: dict[str, Any]) ->
|
|||||||
audit = get_audit_logger()
|
audit = get_audit_logger()
|
||||||
|
|
||||||
if not settings.raw_api_enabled:
|
if not settings.raw_api_enabled:
|
||||||
raise HTTPException(
|
raise ToolError(
|
||||||
|
"Raw API dispatch is disabled (set RAW_API_ENABLED=true to enable).",
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail="Raw API dispatch is disabled (set RAW_API_ENABLED=true to enable).",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
parsed = RawApiRequestArgs.model_validate(arguments)
|
parsed = RawApiRequestArgs.model_validate(arguments)
|
||||||
@@ -82,12 +81,10 @@ async def raw_api_request_tool(gitea: GiteaClient, arguments: dict[str, Any]) ->
|
|||||||
# from policy.yaml — only RAW_API_ALLOW_SENSITIVE overrides it.
|
# from policy.yaml — only RAW_API_ALLOW_SENSITIVE overrides it.
|
||||||
if raw_is_sensitive(endpoint) and not settings.raw_api_allow_sensitive:
|
if raw_is_sensitive(endpoint) and not settings.raw_api_allow_sensitive:
|
||||||
audit.log_access_denied(tool_name="gitea_request", reason="raw_sensitive_path_denied")
|
audit.log_access_denied(tool_name="gitea_request", reason="raw_sensitive_path_denied")
|
||||||
raise HTTPException(
|
raise ToolError(
|
||||||
|
"Endpoint targets an admin/credential surface blocked by the raw-API "
|
||||||
|
"sensitive-path denylist.",
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail=(
|
|
||||||
"Endpoint targets an admin/credential surface blocked by the raw-API "
|
|
||||||
"sensitive-path denylist."
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
repository = parse_raw_repository(endpoint)
|
repository = parse_raw_repository(endpoint)
|
||||||
@@ -108,7 +105,7 @@ async def raw_api_request_tool(gitea: GiteaClient, arguments: dict[str, Any]) ->
|
|||||||
repository=repository,
|
repository=repository,
|
||||||
reason=decision.reason,
|
reason=decision.reason,
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=403, detail=f"Policy denied raw request: {decision.reason}")
|
raise ToolError(f"Policy denied raw request: {decision.reason}", status_code=403)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await gitea.raw_request(method, endpoint, params=parsed.query, json_body=parsed.body)
|
data = await gitea.raw_request(method, endpoint, params=parsed.query, json_body=parsed.body)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Lock the transport-agnostic core boundary.
|
||||||
|
|
||||||
|
The core (tool registry, Gitea client, policy, audit, config, request context,
|
||||||
|
tools) must import cleanly without dragging in the web stack. If a stray
|
||||||
|
``import fastapi`` creeps back into a core module, the local stdio package would
|
||||||
|
gain a needless heavy dependency and the ``[server]`` extra split would leak.
|
||||||
|
|
||||||
|
The check runs in a subprocess because, within the pytest process, FastAPI is
|
||||||
|
already imported by the server tests — so ``'fastapi' in sys.modules`` would be
|
||||||
|
true regardless. A clean interpreter is the only reliable probe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SRC = Path(__file__).resolve().parents[1] / "src"
|
||||||
|
|
||||||
|
# Core modules that must stay free of the web stack.
|
||||||
|
_CORE_MODULES = [
|
||||||
|
"aegis_gitea_mcp.registry",
|
||||||
|
"aegis_gitea_mcp.gitea_client",
|
||||||
|
"aegis_gitea_mcp.policy",
|
||||||
|
"aegis_gitea_mcp.audit",
|
||||||
|
"aegis_gitea_mcp.config",
|
||||||
|
"aegis_gitea_mcp.request_context",
|
||||||
|
"aegis_gitea_mcp.response_limits",
|
||||||
|
"aegis_gitea_mcp.security",
|
||||||
|
"aegis_gitea_mcp.cache",
|
||||||
|
"aegis_gitea_mcp.logging_utils",
|
||||||
|
"aegis_gitea_mcp.mcp_protocol",
|
||||||
|
"aegis_gitea_mcp.errors",
|
||||||
|
"aegis_gitea_mcp.tools.raw_tools",
|
||||||
|
"aegis_gitea_mcp.tools.read_tools",
|
||||||
|
"aegis_gitea_mcp.tools.write_tools",
|
||||||
|
"aegis_gitea_mcp.tools.repository",
|
||||||
|
"aegis_gitea_mcp.tools.arguments",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_core_does_not_import_fastapi() -> None:
|
||||||
|
"""Importing the core in a clean interpreter must not import FastAPI."""
|
||||||
|
imports = "\n".join(f"import {module}" for module in _CORE_MODULES)
|
||||||
|
program = (
|
||||||
|
f"import sys\n{imports}\n"
|
||||||
|
"leaked = [m for m in ('fastapi', 'uvicorn', 'starlette') if m in sys.modules]\n"
|
||||||
|
"assert not leaked, f'core leaked web stack: {leaked}'\n"
|
||||||
|
"print('ok')\n"
|
||||||
|
)
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["PYTHONPATH"] = str(_SRC)
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-c", program],
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"core import boundary violated.\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
||||||
|
)
|
||||||
|
assert "ok" in result.stdout
|
||||||
@@ -6,10 +6,10 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from aegis_gitea_mcp.config import reset_settings
|
from aegis_gitea_mcp.config import reset_settings
|
||||||
|
from aegis_gitea_mcp.errors import ToolError
|
||||||
from aegis_gitea_mcp.tools.arguments import (
|
from aegis_gitea_mcp.tools.arguments import (
|
||||||
extract_repository,
|
extract_repository,
|
||||||
extract_target_path,
|
extract_target_path,
|
||||||
@@ -83,7 +83,7 @@ async def test_lowercase_method_is_normalized(raw_env: None) -> None:
|
|||||||
async def test_delete_denied_when_write_mode_off(raw_env: None) -> None:
|
async def test_delete_denied_when_write_mode_off(raw_env: None) -> None:
|
||||||
"""A write method is denied (no network call) while write-mode is disabled."""
|
"""A write method is denied (no network call) while write-mode is disabled."""
|
||||||
stub = StubRawGitea()
|
stub = StubRawGitea()
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(ToolError) as exc_info:
|
||||||
await raw_api_request_tool(stub, {"method": "DELETE", "path": "/repos/acme/app/issues/1"})
|
await raw_api_request_tool(stub, {"method": "DELETE", "path": "/repos/acme/app/issues/1"})
|
||||||
|
|
||||||
assert exc_info.value.status_code == 403
|
assert exc_info.value.status_code == 403
|
||||||
@@ -135,7 +135,7 @@ async def test_write_denied_for_repo_outside_whitelist(
|
|||||||
monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/other")
|
monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/other")
|
||||||
|
|
||||||
stub = StubRawGitea()
|
stub = StubRawGitea()
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(ToolError) as exc_info:
|
||||||
await raw_api_request_tool(stub, {"method": "POST", "path": "/repos/acme/app/issues"})
|
await raw_api_request_tool(stub, {"method": "POST", "path": "/repos/acme/app/issues"})
|
||||||
|
|
||||||
assert exc_info.value.status_code == 403
|
assert exc_info.value.status_code == 403
|
||||||
@@ -158,7 +158,7 @@ async def test_non_repository_write_denied(monkeypatch: pytest.MonkeyPatch, tmp_
|
|||||||
monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/app")
|
monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/app")
|
||||||
|
|
||||||
stub = StubRawGitea()
|
stub = StubRawGitea()
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(ToolError) as exc_info:
|
||||||
await raw_api_request_tool(stub, {"method": "POST", "path": "/user/repos"})
|
await raw_api_request_tool(stub, {"method": "POST", "path": "/user/repos"})
|
||||||
|
|
||||||
assert exc_info.value.status_code == 403
|
assert exc_info.value.status_code == 403
|
||||||
@@ -173,7 +173,7 @@ async def test_non_repository_write_denied(monkeypatch: pytest.MonkeyPatch, tmp_
|
|||||||
async def test_sensitive_paths_denied_on_get(raw_env: None, path: str) -> None:
|
async def test_sensitive_paths_denied_on_get(raw_env: None, path: str) -> None:
|
||||||
"""Admin/credential surfaces are denied for every method, including GET."""
|
"""Admin/credential surfaces are denied for every method, including GET."""
|
||||||
stub = StubRawGitea()
|
stub = StubRawGitea()
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(ToolError) as exc_info:
|
||||||
await raw_api_request_tool(stub, {"method": "GET", "path": path})
|
await raw_api_request_tool(stub, {"method": "GET", "path": path})
|
||||||
|
|
||||||
assert exc_info.value.status_code == 403
|
assert exc_info.value.status_code == 403
|
||||||
@@ -253,7 +253,7 @@ async def test_raw_api_disabled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path)
|
|||||||
monkeypatch.setenv("RAW_API_ENABLED", "false")
|
monkeypatch.setenv("RAW_API_ENABLED", "false")
|
||||||
|
|
||||||
stub = StubRawGitea()
|
stub = StubRawGitea()
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(ToolError) as exc_info:
|
||||||
await raw_api_request_tool(stub, {"method": "GET", "path": "/repos/acme/app"})
|
await raw_api_request_tool(stub, {"method": "GET", "path": "/repos/acme/app"})
|
||||||
assert exc_info.value.status_code == 403
|
assert exc_info.value.status_code == 403
|
||||||
assert "disabled" in str(exc_info.value.detail)
|
assert "disabled" in str(exc_info.value.detail)
|
||||||
|
|||||||
Reference in New Issue
Block a user