diff --git a/src/aegis_gitea_mcp/errors.py b/src/aegis_gitea_mcp/errors.py new file mode 100644 index 0000000..5219ccb --- /dev/null +++ b/src/aegis_gitea_mcp/errors.py @@ -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 diff --git a/src/aegis_gitea_mcp/registry.py b/src/aegis_gitea_mcp/registry.py new file mode 100644 index 0000000..583f7f5 --- /dev/null +++ b/src/aegis_gitea_mcp/registry.py @@ -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", +] diff --git a/src/aegis_gitea_mcp/server.py b/src/aegis_gitea_mcp/server.py index 36d7376..3831303 100644 --- a/src/aegis_gitea_mcp/server.py +++ b/src/aegis_gitea_mcp/server.py @@ -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.cache import BoundedTTLCache from aegis_gitea_mcp.config import get_settings +from aegis_gitea_mcp.errors import ToolError from aegis_gitea_mcp.gitea_client import ( GiteaAuthenticationError, 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.policy import PolicyError, get_policy_engine 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 ( clear_gitea_auth_context, 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.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__) @@ -372,61 +324,6 @@ class AutomationJobRequest(BaseModel): 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: """Build absolute metadata URL for OAuth challenge responses.""" 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 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": # Security decision: sanitize outbound payloads to prevent accidental secret exfiltration. diff --git a/src/aegis_gitea_mcp/tools/raw_tools.py b/src/aegis_gitea_mcp/tools/raw_tools.py index e73ca64..3c0caaa 100644 --- a/src/aegis_gitea_mcp/tools/raw_tools.py +++ b/src/aegis_gitea_mcp/tools/raw_tools.py @@ -22,10 +22,9 @@ from __future__ import annotations import json from typing import Any -from fastapi import HTTPException - from aegis_gitea_mcp.audit import get_audit_logger from aegis_gitea_mcp.config import get_settings +from aegis_gitea_mcp.errors import ToolError from aegis_gitea_mcp.gitea_client import ( GiteaAuthenticationError, GiteaAuthorizationError, @@ -68,9 +67,9 @@ async def raw_api_request_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> audit = get_audit_logger() 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, - detail="Raw API dispatch is disabled (set RAW_API_ENABLED=true to enable).", ) 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. 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") - raise HTTPException( + raise ToolError( + "Endpoint targets an admin/credential surface blocked by the raw-API " + "sensitive-path denylist.", status_code=403, - detail=( - "Endpoint targets an admin/credential surface blocked by the raw-API " - "sensitive-path denylist." - ), ) repository = parse_raw_repository(endpoint) @@ -108,7 +105,7 @@ async def raw_api_request_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> repository=repository, 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: data = await gitea.raw_request(method, endpoint, params=parsed.query, json_body=parsed.body) diff --git a/tests/test_core_boundary.py b/tests/test_core_boundary.py new file mode 100644 index 0000000..71803cd --- /dev/null +++ b/tests/test_core_boundary.py @@ -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 diff --git a/tests/test_raw_api.py b/tests/test_raw_api.py index 400175a..88b623c 100644 --- a/tests/test_raw_api.py +++ b/tests/test_raw_api.py @@ -6,10 +6,10 @@ from pathlib import Path from typing import Any import pytest -from fastapi import HTTPException from pydantic import ValidationError from aegis_gitea_mcp.config import reset_settings +from aegis_gitea_mcp.errors import ToolError from aegis_gitea_mcp.tools.arguments import ( extract_repository, 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: """A write method is denied (no network call) while write-mode is disabled.""" 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"}) 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") 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"}) 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") 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"}) 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: """Admin/credential surfaces are denied for every method, including GET.""" 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}) 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") 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"}) assert exc_info.value.status_code == 403 assert "disabled" in str(exc_info.value.detail)