Detect stale MCP runtime before mutation tools execute #545
@@ -664,6 +664,39 @@ def _verify_role_mutation_workspace(
|
|||||||
task: str | None = None,
|
task: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Bind reviewer/merger mutations to the active namespace workspace (#510)."""
|
"""Bind reviewer/merger mutations to the active namespace workspace (#510)."""
|
||||||
|
# Check running runtimes to prevent stale mutations
|
||||||
|
try:
|
||||||
|
if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
|
||||||
|
config = gitea_config.load_config()
|
||||||
|
required_permission = task_capability_map.required_permission(task) if task else None
|
||||||
|
matching_profiles = []
|
||||||
|
if required_permission and config and "profiles" in config:
|
||||||
|
for p_name, p_data in config["profiles"].items():
|
||||||
|
p_allowed = p_data.get("allowed_operations") or []
|
||||||
|
p_forbidden = p_data.get("forbidden_operations") or []
|
||||||
|
p_allowed_n = []
|
||||||
|
for op in p_allowed:
|
||||||
|
try:
|
||||||
|
p_allowed_n.append(gitea_config.normalize_operation(op))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
p_forbidden_n = []
|
||||||
|
for op in p_forbidden:
|
||||||
|
try:
|
||||||
|
p_forbidden_n.append(gitea_config.normalize_operation(op))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ok, _ = gitea_config.check_operation(required_permission, p_allowed_n, p_forbidden_n)
|
||||||
|
if ok:
|
||||||
|
matching_profiles.append(p_name)
|
||||||
|
runtime_reasons = _check_mcp_runtimes_diagnostics(task or "unknown", matching_profiles)
|
||||||
|
if runtime_reasons:
|
||||||
|
raise RuntimeError("; ".join(runtime_reasons))
|
||||||
|
except Exception as exc:
|
||||||
|
if "stale-runtime:" in str(exc):
|
||||||
|
raise RuntimeError(str(exc))
|
||||||
|
pass
|
||||||
|
|
||||||
role = _effective_workspace_role()
|
role = _effective_workspace_role()
|
||||||
git_state = issue_lock_worktree.read_worktree_git_state(
|
git_state = issue_lock_worktree.read_worktree_git_state(
|
||||||
_resolve_preflight_workspace_path(worktree_path)
|
_resolve_preflight_workspace_path(worktree_path)
|
||||||
@@ -8304,6 +8337,102 @@ def gitea_route_task_session(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> list[str]:
|
||||||
|
"""Check running runtimes and return errors if they are missing or stale."""
|
||||||
|
import subprocess
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
reasons = []
|
||||||
|
code_path = os.path.join(PROJECT_ROOT, "gitea_mcp_server.py")
|
||||||
|
if not os.path.exists(code_path):
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
code_mtime = datetime.fromtimestamp(os.path.getmtime(code_path))
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["ps", "-o", "pid,lstart,command", "-ax"],
|
||||||
|
capture_output=True, text=True, check=True
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return [f"stale-runtime: failed to list running processes: {exc}"]
|
||||||
|
|
||||||
|
self_pid = os.getpid()
|
||||||
|
self_stale = False
|
||||||
|
|
||||||
|
running_profiles = {}
|
||||||
|
for line in proc.stdout.splitlines()[1:]:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or "mcp_server.py" not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = line.split(None, 6)
|
||||||
|
if len(parts) < 7:
|
||||||
|
continue
|
||||||
|
pid_str = parts[0]
|
||||||
|
lstart_str = " ".join(parts[1:6])
|
||||||
|
|
||||||
|
try:
|
||||||
|
pid = int(pid_str)
|
||||||
|
start_time = datetime.strptime(lstart_str, "%a %b %d %H:%M:%S %Y")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
env_proc = subprocess.run(
|
||||||
|
["ps", "eww", str(pid)],
|
||||||
|
capture_output=True, text=True, check=True
|
||||||
|
)
|
||||||
|
env_out = env_proc.stdout
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
profile = "gitea-default"
|
||||||
|
match = re.search(r'\bGITEA_MCP_PROFILE=([^\s]+)', env_out)
|
||||||
|
if match:
|
||||||
|
profile = match.group(1)
|
||||||
|
|
||||||
|
is_stale = start_time < code_mtime
|
||||||
|
if pid == self_pid and is_stale:
|
||||||
|
self_stale = True
|
||||||
|
|
||||||
|
if profile not in running_profiles or start_time > running_profiles[profile]["start_time"]:
|
||||||
|
running_profiles[profile] = {
|
||||||
|
"pid": pid,
|
||||||
|
"start_time": start_time,
|
||||||
|
"is_stale": is_stale
|
||||||
|
}
|
||||||
|
|
||||||
|
if self_stale:
|
||||||
|
reasons.append(
|
||||||
|
"stale-runtime: The active Gitea MCP server process is stale (running code from before changes were merged). "
|
||||||
|
"Please fully restart the Gitea MCP server (e.g. touch /Users/jasonwalker/.gemini/config/mcp_config.json) and retry."
|
||||||
|
)
|
||||||
|
|
||||||
|
if matching_profiles:
|
||||||
|
any_running = False
|
||||||
|
any_fresh = False
|
||||||
|
for mp in matching_profiles:
|
||||||
|
if mp in running_profiles:
|
||||||
|
any_running = True
|
||||||
|
if not running_profiles[mp]["is_stale"]:
|
||||||
|
any_fresh = True
|
||||||
|
break
|
||||||
|
if not any_running:
|
||||||
|
reasons.append(
|
||||||
|
f"stale-runtime: None of the matching profiles for task '{task}' ({matching_profiles}) are running in the OS. "
|
||||||
|
"Please restart the MCP server to ensure they are spawned."
|
||||||
|
)
|
||||||
|
elif not any_fresh:
|
||||||
|
reasons.append(
|
||||||
|
f"stale-runtime: All matching profiles for task '{task}' ({matching_profiles}) are running but stale. "
|
||||||
|
"Please fully restart the Gitea MCP server and retry."
|
||||||
|
)
|
||||||
|
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def gitea_resolve_task_capability(
|
def gitea_resolve_task_capability(
|
||||||
task: str,
|
task: str,
|
||||||
@@ -8414,6 +8543,16 @@ def gitea_resolve_task_capability(
|
|||||||
configured = len(matching_profiles) > 0
|
configured = len(matching_profiles) > 0
|
||||||
available_in_session = allowed_in_current_session
|
available_in_session = allowed_in_current_session
|
||||||
|
|
||||||
|
if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
|
||||||
|
runtime_reasons = _check_mcp_runtimes_diagnostics(task, matching_profiles)
|
||||||
|
if runtime_reasons:
|
||||||
|
restart_required = True
|
||||||
|
reason_msg = "; ".join(runtime_reasons)
|
||||||
|
next_safe_action = (
|
||||||
|
"stale-runtime: Gitea MCP runtime conflict or missing process detected. "
|
||||||
|
"Please fully restart the Gitea MCP server and retry."
|
||||||
|
)
|
||||||
|
|
||||||
if not allowed_in_current_session:
|
if not allowed_in_current_session:
|
||||||
if configured and switching:
|
if configured and switching:
|
||||||
restart_required = True
|
restart_required = True
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Live health-check script to verify Gitea MCP namespace connections.
|
||||||
|
|
||||||
|
Spawns the MCP server processes as defined in the IDE's global config,
|
||||||
|
performs the JSON-RPC handshake, and queries the tools list to verify
|
||||||
|
that the connection is fully operational and doesn't return EOF.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def test_connection(name, config):
|
||||||
|
print(f"Testing MCP connection for '{name}'...")
|
||||||
|
command = config.get("command")
|
||||||
|
args = config.get("args", [])
|
||||||
|
env = config.get("env", {})
|
||||||
|
|
||||||
|
# Merge current environment
|
||||||
|
run_env = os.environ.copy()
|
||||||
|
run_env.update(env)
|
||||||
|
|
||||||
|
# Spawn subprocess
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[command] + args,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
|
env=run_env
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [FAIL] Failed to spawn process: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Send initialize request
|
||||||
|
init_req = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "healthcheck", "version": "1.0"}
|
||||||
|
},
|
||||||
|
"id": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc.stdin.write(json.dumps(init_req) + "\n")
|
||||||
|
proc.stdin.flush()
|
||||||
|
|
||||||
|
# Read response
|
||||||
|
line = proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
stderr_content = proc.stderr.read()
|
||||||
|
print(f" [FAIL] Received EOF from process. Stderr:\n{stderr_content}")
|
||||||
|
proc.terminate()
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f" [OK] Received initialize response: {line.strip()[:150]}...")
|
||||||
|
|
||||||
|
# Send initialized notification
|
||||||
|
init_notif = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "notifications/initialized"
|
||||||
|
}
|
||||||
|
proc.stdin.write(json.dumps(init_notif) + "\n")
|
||||||
|
proc.stdin.flush()
|
||||||
|
|
||||||
|
# Send tools/list request
|
||||||
|
list_req = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "tools/list",
|
||||||
|
"params": {},
|
||||||
|
"id": 2
|
||||||
|
}
|
||||||
|
proc.stdin.write(json.dumps(list_req) + "\n")
|
||||||
|
proc.stdin.flush()
|
||||||
|
|
||||||
|
line = proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
print(" [FAIL] Received EOF on tools/list request.")
|
||||||
|
proc.terminate()
|
||||||
|
return False
|
||||||
|
|
||||||
|
res = json.loads(line)
|
||||||
|
if "error" in res:
|
||||||
|
print(f" [FAIL] Server returned error: {res['error']}")
|
||||||
|
proc.terminate()
|
||||||
|
return False
|
||||||
|
|
||||||
|
tools = res.get("result", {}).get("tools", [])
|
||||||
|
tool_names = [t.get("name") for t in tools]
|
||||||
|
print(f" [OK] Successfully retrieved {len(tool_names)} tools: {tool_names[:5]}...")
|
||||||
|
|
||||||
|
proc.terminate()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [FAIL] Error during handshake: {e}")
|
||||||
|
proc.terminate()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
config_path = "/Users/jasonwalker/.gemini/config/mcp_config.json"
|
||||||
|
try:
|
||||||
|
with open(config_path) as f:
|
||||||
|
mcp_config = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to load mcp_config.json: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
servers = mcp_config.get("mcpServers", {})
|
||||||
|
failed = False
|
||||||
|
for name in ["gitea-author", "gitea-reviewer"]:
|
||||||
|
if name in servers:
|
||||||
|
if not test_connection(name, servers[name]):
|
||||||
|
failed = True
|
||||||
|
else:
|
||||||
|
print(f"Server '{name}' not found in mcp_config.json")
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("All Gitea MCP connection tests passed!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import gitea_mcp_server
|
||||||
|
|
||||||
|
class TestMcpStaleRuntime(unittest.TestCase):
|
||||||
|
@patch("subprocess.run")
|
||||||
|
@patch("os.path.getmtime")
|
||||||
|
@patch("os.path.exists")
|
||||||
|
@patch("os.getpid")
|
||||||
|
@patch.dict("os.environ", {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"})
|
||||||
|
def test_stale_and_missing_runtimes(self, mock_getpid, mock_exists, mock_getmtime, mock_run):
|
||||||
|
# Setup mocks
|
||||||
|
mock_getpid.return_value = 12345
|
||||||
|
mock_exists.return_value = True
|
||||||
|
|
||||||
|
# Code modification time: Jul 8 2026, 14:00:00
|
||||||
|
code_time = datetime(2026, 7, 8, 14, 0, 0)
|
||||||
|
mock_getmtime.return_value = code_time.timestamp()
|
||||||
|
|
||||||
|
# Mock ps -ax output
|
||||||
|
# PID 12345 is self (started at 13:00:00 - stale)
|
||||||
|
# PID 54321 is prgs-author (started at 15:00:00 - fresh)
|
||||||
|
# prgs-reviewer is missing
|
||||||
|
ps_output = (
|
||||||
|
" PID LSTART COMMAND\n"
|
||||||
|
"12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n"
|
||||||
|
"54321 Wed Jul 8 15:00:00 2026 /path/to/python mcp_server.py\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_run_ps = MagicMock()
|
||||||
|
mock_run_ps.stdout = ps_output
|
||||||
|
|
||||||
|
# Mock env output for ps eww
|
||||||
|
mock_run_env12345 = MagicMock()
|
||||||
|
mock_run_env12345.stdout = "GITEA_MCP_PROFILE=prgs-reconciler"
|
||||||
|
|
||||||
|
mock_run_env54321 = MagicMock()
|
||||||
|
mock_run_env54321.stdout = "GITEA_MCP_PROFILE=prgs-author"
|
||||||
|
|
||||||
|
def side_effect(args, **kwargs):
|
||||||
|
if args[0] == "ps" and "eww" in args:
|
||||||
|
pid = args[2]
|
||||||
|
if pid == "12345":
|
||||||
|
return mock_run_env12345
|
||||||
|
elif pid == "54321":
|
||||||
|
return mock_run_env54321
|
||||||
|
elif args[0] == "ps":
|
||||||
|
return mock_run_ps
|
||||||
|
raise ValueError(f"Unexpected subprocess run args: {args}")
|
||||||
|
|
||||||
|
mock_run.side_effect = side_effect
|
||||||
|
|
||||||
|
# Test 1: required reviewer role matching prgs-reviewer (which is missing)
|
||||||
|
reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("review_pr", ["prgs-reviewer"])
|
||||||
|
|
||||||
|
self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons))
|
||||||
|
self.assertTrue(any("stale-runtime: None of the matching profiles for task" in r for r in reasons))
|
||||||
|
|
||||||
|
# Test 2: required author role matching prgs-author (which is fresh)
|
||||||
|
reasons_author = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"])
|
||||||
|
|
||||||
|
# Still contains self stale error
|
||||||
|
self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons_author))
|
||||||
|
# But does NOT contain missing author profile error
|
||||||
|
self.assertFalse(any("None of the matching profiles for task" in r for r in reasons_author))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user