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:
2026-06-27 10:49:46 +02:00
parent dd253f87e5
commit 7da0c46de8
6 changed files with 262 additions and 122 deletions
+25
View File
@@ -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
+151
View File
@@ -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",
]
+9 -106
View File
@@ -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.
+7 -10
View File
@@ -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)
+64
View File
@@ -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 -6
View File
@@ -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)