From c89ae2eb5cf7a382e643cffecc3cb952672ea584 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Sun, 5 Jul 2026 21:07:42 -0400 Subject: [PATCH 1/5] Rename mcp_server.py to gitea_mcp_server.py --- mcp_server.py => gitea_mcp_server.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mcp_server.py => gitea_mcp_server.py (100%) diff --git a/mcp_server.py b/gitea_mcp_server.py similarity index 100% rename from mcp_server.py rename to gitea_mcp_server.py From 1948eae184408465f298d2109915626b03a3b432 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Sun, 5 Jul 2026 21:13:15 -0400 Subject: [PATCH 2/5] feat(workflow-guard): verify MCP runtime clean/not mid-merge before starting reviewer work --- role_session_router.py | 54 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/role_session_router.py b/role_session_router.py index b9a0406..23eb945 100644 --- a/role_session_router.py +++ b/role_session_router.py @@ -5,12 +5,14 @@ returns a route result before any downstream mutation tools run. """ from __future__ import annotations +import os ROUTE_ALLOWED = "allowed_current_session" ROUTE_WRONG_ROLE = "wrong_role_stop" ROUTE_TO_AUTHOR = "route_to_author_session" ROUTE_TO_REVIEWER = "route_to_reviewer_session" ROUTE_AMBIGUOUS = "ambiguous_task_stop" +ROUTE_INFRA_STOP = "infra_stop" REVIEWER_TASKS = frozenset({ "review_pr", @@ -93,6 +95,23 @@ def route_task_session( _record_route(result) return result + if required_role == "reviewer" and check_mid_merge(): + result = { + "task_type": task_type, + "required_role": required_role, + "active_role": active_role_kind, + "active_profile": active_profile, + "route_result": ROUTE_INFRA_STOP, + "downstream_allowed": False, + "reasons": [ + "infra_stop: Unresolved merge conflict or mid-merge state detected in MCP runtime source.", + "Please resolve all conflicts manually, finish/abort the merge, restart the MCP server, and retry." + ], + "message": "infra_stop: Unresolved merge conflict or mid-merge state detected in MCP runtime source.", + } + _record_route(result) + return result + if allowed_in_current_session: result = { "task_type": task_type, @@ -192,4 +211,37 @@ def check_author_mutation_after_reviewer_stop(mutation_task: str) -> tuple[bool, "task and relaunches an author MCP session.", f"Attempted fallback mutation: {mutation_task}", ] - return True, [] \ No newline at end of file + return True, [] + + +def check_mid_merge() -> bool: + """Return True if the repository is mid-merge, mid-rebase, or has conflict markers.""" + project_root = os.path.dirname(os.path.abspath(__file__)) + git_dir = os.path.join(project_root, ".git") + if os.path.exists(git_dir): + if (os.path.exists(os.path.join(git_dir, "MERGE_HEAD")) + or os.path.exists(os.path.join(git_dir, "rebase-merge")) + or os.path.exists(os.path.join(git_dir, "rebase-apply"))): + return True + + # Scan python files for conflict markers + conflict_patterns = [ + b"<" * 7 + b" ", + b"=" * 7 + b"\n", + b"=" * 7 + b"\r\n", + b">" * 7 + b" " + ] + for root, dirs, files in os.walk(project_root): + if any(p in root for p in ("venv", ".git", ".pytest_cache", "branches")): + continue + for file in files: + if file.endswith(".py"): + file_path = os.path.join(root, file) + try: + with open(file_path, "rb") as f: + content = f.read() + if any(pattern in content for pattern in conflict_patterns): + return True + except Exception: + pass + return False \ No newline at end of file From cb974d659f69ada0ef4b74c3424fa970ed60dfdc Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Sun, 5 Jul 2026 21:13:38 -0400 Subject: [PATCH 3/5] feat(workflow-guard): check mid-merge in gitea_resolve_task_capability --- gitea_mcp_server.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 6eb0ae5..19d168d 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -4280,6 +4280,31 @@ def gitea_resolve_task_capability( required_permission = TASK_MAP[task]["permission"] required_role = TASK_MAP[task]["role"] + if required_role == "reviewer" and role_session_router.check_mid_merge(): + profile = get_profile() + h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None) + username = _authenticated_username(h) if h else None + next_safe_action = ( + "infra_stop: Unresolved merge conflict or mid-merge state detected in MCP runtime source. " + "Please resolve all conflicts manually, finish/abort the merge, restart the MCP server, and retry." + ) + return { + "requested_task": task, + "required_operation_permission": required_permission, + "required_role_kind": required_role, + "active_profile": profile["profile_name"], + "active_identity": username, + "active_profile_allowed_operations": profile.get("allowed_operations") or [], + "allowed_in_current_session": False, + "stop_required": True, + "infra_stop": True, + "task_role_guidance": [next_safe_action], + "matching_configured_profile": [], + "runtime_switching_supported": gitea_config.is_runtime_switching_enabled(), + "different_mcp_namespace_required": False, + "exact_safe_next_action": next_safe_action, + } + record_preflight_check("capability", required_role) profile = get_profile() From 2cd61a90fe22e55ebbf30df9c4b387352f30e431 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Sun, 5 Jul 2026 21:13:40 -0400 Subject: [PATCH 4/5] feat(runtime-isolation): loader, health checks, tests, and documentation for Issue #221 --- docs/gitea-execution-profiles.md | 11 ++++++ mcp_server.py | 60 ++++++++++++++++++++++++++++++++ tests/test_health.py | 58 ++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 mcp_server.py create mode 100644 tests/test_health.py diff --git a/docs/gitea-execution-profiles.md b/docs/gitea-execution-profiles.md index e4ec36e..061ae70 100644 --- a/docs/gitea-execution-profiles.md +++ b/docs/gitea-execution-profiles.md @@ -298,6 +298,17 @@ When dynamic profile switching is enabled and a profile is activated via `gitea_ 2. Call `gitea_whoami` with the target remote to prove and verify the fresh Gitea authenticated identity. This guarantees the active profile operations align with the actual Gitea authenticated user credential. +## Gitea MCP Runtime Isolation and Worktree Safety + +To ensure high availability and prevent broken feature worktrees from disabling essential security/identity controls, the Gitea MCP server implements runtime isolation: + +- **Startup Conflict Check:** The MCP server (`mcp_server.py`) acts as a conflict-free loader. On startup, it scans all Python files in the directory for unresolved git merge conflicts (`<<<<<<<`, `=======`, `>>>>>>>`). If any are found, it prints an `infra_stop` message and exits immediately. +- **Workflow Guard:** Before starting reviewer work, task routing checks if the MCP runtime source is mid-merge (by checking for `.git/MERGE_HEAD` or conflict markers). If dirty, it returns `infra_stop` (never `wrong_role_stop` or empty queue) to prevent unsafe mutations. +- **Recovery Instructions:** To recover from an `infra_stop` state: + 1. Resolve all merge conflicts in the local repository or abort the merge (`git merge --abort` / `git rebase --abort`). + 2. Restart the Gitea MCP server process. + 3. Retry the task. + ## Relationship to roadmap issues This document defines the **model only**. Related work is tracked separately diff --git a/mcp_server.py b/mcp_server.py new file mode 100644 index 0000000..4288ef1 --- /dev/null +++ b/mcp_server.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Gitea MCP Server — exposes Gitea operations as MCP tools. + +Runs over stdio. All tools authenticate via macOS keychain (git credential fill). +""" +import os +import sys + +# Startup health check: scan all python files in the Gitea-Tools directory for unresolved conflict markers. +def check_conflict_markers(): + dir_path = os.path.dirname(os.path.abspath(__file__)) + # Construct conflict patterns dynamically so the loader does not match itself + conflict_patterns = [ + b"<" * 7 + b" ", + b"=" * 7 + b"\n", + b"=" * 7 + b"\r\n", + b">" * 7 + b" " + ] + for root, dirs, files in os.walk(dir_path): + if any(p in root for p in ("venv", ".git", ".pytest_cache", "branches")): + continue + for file in files: + if file.endswith(".py"): + file_path = os.path.join(root, file) + try: + with open(file_path, "rb") as f: + content = f.read() + if any(pattern in content for pattern in conflict_patterns): + rel_path = os.path.relpath(file_path, dir_path) + print( + f"infra_stop: Unresolved merge conflict detected in {rel_path}. " + "Please resolve all conflicts manually, finish/abort the merge, " + "restart the MCP server, and retry.", + file=sys.stderr + ) + sys.exit(1) + except Exception: + pass + +check_conflict_markers() + +# Execute the actual server logic via exec in this namespace. +impl_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gitea_mcp_server.py") +try: + with open(impl_path, "rb") as f: + # Override __file__ global so that inspect.getsource(mcp_server) returns the implementation source + globals()["__file__"] = impl_path + code = compile(f.read(), impl_path, "exec") + exec(code, globals()) +except Exception as e: + if isinstance(e, SyntaxError): + # Fallback if a syntax error/conflict marker was introduced in gitea_mcp_server.py + print( + f"infra_stop: Unresolved merge conflict or syntax error in gitea_mcp_server.py. " + "Please resolve all conflicts manually, finish/abort the merge, " + "restart the MCP server, and retry.", + file=sys.stderr + ) + sys.exit(1) + raise e diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..a4d209b --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,58 @@ +import os +import subprocess +import sys +import unittest +from unittest.mock import patch +import role_session_router +from mcp_server import gitea_route_task_session, gitea_resolve_task_capability + +class TestMCPHealth(unittest.TestCase): + + def setUp(self): + self.project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + self.temp_file = os.path.join(self.project_root, "test_temp_conflict.py") + if os.path.exists(self.temp_file): + os.remove(self.temp_file) + + def tearDown(self): + if os.path.exists(self.temp_file): + os.remove(self.temp_file) + + def test_startup_conflict_detection(self): + # Create a Python file with conflict markers constructed dynamically + with open(self.temp_file, "w") as f: + f.write("<" * 7 + " HEAD\n") + f.write("print('hello')\n") + f.write("=" * 7 + "\n") + f.write("print('world')\n") + f.write(">" * 7 + " main\n") + + # Run mcp_server.py + venv_python = os.path.join(self.project_root, "venv", "bin", "python") + script_path = os.path.join(self.project_root, "mcp_server.py") + res = subprocess.run( + [venv_python, script_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=self.project_root + ) + + self.assertEqual(res.returncode, 1) + stderr = res.stderr.decode() + self.assertIn("infra_stop", stderr) + self.assertIn("Unresolved merge conflict detected in test_temp_conflict.py", stderr) + + @patch("role_session_router.check_mid_merge", return_value=True) + @patch("mcp_server.get_profile", return_value={"profile_name": "prgs-reviewer", "allowed_operations": ["gitea.pr.review"]}) + def test_route_task_session_blocks_during_merge(self, mock_profile, mock_check): + res = gitea_route_task_session("review_pr") + self.assertEqual(res["route_result"], "infra_stop") + self.assertIn("infra_stop", res["message"]) + + @patch("role_session_router.check_mid_merge", return_value=True) + @patch("mcp_server.get_profile", return_value={"profile_name": "prgs-reviewer", "allowed_operations": ["gitea.pr.review"]}) + def test_resolve_task_capability_blocks_during_merge(self, mock_profile, mock_check): + res = gitea_resolve_task_capability("review_pr") + self.assertTrue(res["infra_stop"]) + self.assertFalse(res["allowed_in_current_session"]) + self.assertIn("infra_stop", res["exact_safe_next_action"]) From 2f5e0408fd2d50db672d57162d6a39ab2c60989e Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Sun, 5 Jul 2026 21:14:37 -0400 Subject: [PATCH 5/5] test(review-proofs): add required Workspace mutations field to GOOD_HANDOFF mock --- tests/test_review_proofs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_review_proofs.py b/tests/test_review_proofs.py index 804c6c3..47ee28d 100644 --- a/tests/test_review_proofs.py +++ b/tests/test_review_proofs.py @@ -205,6 +205,7 @@ GOOD_HANDOFF = "\n".join([ "- Files changed: review_proofs.py", "- Validation: 700 passed, 6 skipped", "- Mutations: review only", + "- Workspace mutations: none", "- Current status: PR open", "- Blockers: none", "- Next: merge if approved",