fix: resolve stale MCP runtime EOF follow-ups (#531)

- fail closed when runtime diagnostics fail unexpectedly
- move MCP connection healthcheck under scripts/
- document #543 as the remaining IDE namespace proof
- keep namespace binding test isolated from mocked git metadata
This commit is contained in:
2026-07-09 08:06:54 -04:00
parent c64809134f
commit be0123c6dd
5 changed files with 84 additions and 35 deletions
+1
View File
@@ -9,3 +9,4 @@ Pending architectural and workflow decisions:
- **Operation-scoped role selection (#228)** — Move from launcher-profile-dependent routing to per-operation profile resolution. - **Operation-scoped role selection (#228)** — Move from launcher-profile-dependent routing to per-operation profile resolution.
- **Gitea-Tools wiki for dadeschools remote** — This bootstrap covers `prgs`; dadeschools instance wiki parity is undecided. - **Gitea-Tools wiki for dadeschools remote** — This bootstrap covers `prgs`; dadeschools instance wiki parity is undecided.
- **Server-side self-merge block** — Complement tool gates with Gitea branch protection where available. - **Server-side self-merge block** — Complement tool gates with Gitea branch protection where available.
- **IDE-namespace callable-tool proof (#543)** — Remaining follow-up. PR #545 (stale MCP runtime race-control) adds runtime staleness guards before mutations and improves diagnostics, but does not include a full cross-namespace callable tool proof for IDE-spawned MCP sessions. Track completion of direct "callable from IDE namespace" verification under #543.
+14 -4
View File
@@ -694,8 +694,13 @@ def _verify_role_mutation_workspace(
raise RuntimeError("; ".join(runtime_reasons)) raise RuntimeError("; ".join(runtime_reasons))
except Exception as exc: except Exception as exc:
if "stale-runtime:" in str(exc): if "stale-runtime:" in str(exc):
raise RuntimeError(str(exc)) raise RuntimeError(str(exc)) from exc
pass # Fail-closed: unexpected error while performing stale-runtime guard must not be swallowed.
# Surface an actionable diagnostic instead of allowing a mutation under unknown runtime state.
raise RuntimeError(
f"stale-runtime: runtime diagnostic check failed unexpectedly ({type(exc).__name__}: {exc}). "
"Failing closed. Verify MCP server processes, profile configuration, and OS process visibility; retry after full MCP server restart."
) from exc
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(
@@ -8338,7 +8343,12 @@ def gitea_route_task_session(
def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> list[str]: 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.""" """Check running runtimes and return errors if they are missing or stale.
NOTE: IDE-namespace callable-tool proof for #543 is a documented follow-up
if this change does not fully close the gap for tools invoked from
IDE-launched MCP client namespaces. See docs/wiki/Open-Decisions.md.
"""
import subprocess import subprocess
import re import re
from datetime import datetime from datetime import datetime
@@ -8407,7 +8417,7 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) ->
if self_stale: if self_stale:
reasons.append( reasons.append(
"stale-runtime: The active Gitea MCP server process is stale (running code from before changes were merged). " "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." "Please fully restart the Gitea MCP server (for the affected profile) and retry."
) )
if matching_profiles: if matching_profiles:
+29 -4
View File
@@ -1,10 +1,20 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Live health-check script to verify Gitea MCP namespace connections. """Live health-check script to verify Gitea MCP namespace connections.
Spawns the MCP server processes as defined in the IDE's global config, Spawns the MCP server processes as defined in a config file,
performs the JSON-RPC handshake, and queries the tools list to verify performs the JSON-RPC handshake, and queries the tools list to verify
that the connection is fully operational and doesn't return EOF. that the connection is fully operational and doesn't return EOF.
Usage:
python scripts/test_mcp_conn.py [config.json]
Env:
GITEA_MCP_HEALTHCHECK_CONFIG Path to mcpServers JSON config (e.g. ~/.gemini/config/mcp_config.json)
The config file must contain an object with "mcpServers": { "gitea-author": {...}, ... }
Each server entry has "command", optional "args", optional "env".
""" """
import argparse
import json import json
import os import os
import subprocess import subprocess
@@ -103,12 +113,26 @@ def test_connection(name, config):
return False return False
def main(): def main():
config_path = "/Users/jasonwalker/.gemini/config/mcp_config.json" parser = argparse.ArgumentParser(description="Gitea MCP connection health check")
parser.add_argument(
"config",
nargs="?",
default=os.environ.get("GITEA_MCP_HEALTHCHECK_CONFIG"),
help="Path to MCP config JSON with mcpServers (or set GITEA_MCP_HEALTHCHECK_CONFIG)"
)
args = parser.parse_args()
config_path = args.config
if not config_path:
print("ERROR: No config path provided. Pass as argument or set GITEA_MCP_HEALTHCHECK_CONFIG.")
print("Example: GITEA_MCP_HEALTHCHECK_CONFIG=/path/to/mcp_config.json python scripts/test_mcp_conn.py")
sys.exit(2)
try: try:
with open(config_path) as f: with open(config_path) as f:
mcp_config = json.load(f) mcp_config = json.load(f)
except Exception as e: except Exception as e:
print(f"Failed to load mcp_config.json: {e}") print(f"Failed to load config {config_path}: {e}")
sys.exit(1) sys.exit(1)
servers = mcp_config.get("mcpServers", {}) servers = mcp_config.get("mcpServers", {})
@@ -118,7 +142,7 @@ def main():
if not test_connection(name, servers[name]): if not test_connection(name, servers[name]):
failed = True failed = True
else: else:
print(f"Server '{name}' not found in mcp_config.json") print(f"Server '{name}' not found in config")
if failed: if failed:
sys.exit(1) sys.exit(1)
@@ -127,3 +151,4 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+14 -1
View File
@@ -234,7 +234,20 @@ class TestNamespaceWorkspaceIntegration(unittest.TestCase):
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n") mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
srv._preflight_resolved_role = "reviewer" srv._preflight_resolved_role = "reviewer"
clean_git_state = {
"current_branch": "master",
"porcelain_status": "",
"head_sha": "abc123",
}
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT): with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()): with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
resolved = srv._verify_role_mutation_workspace("prgs") with mock.patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=clean_git_state,
):
with mock.patch(
"gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha",
return_value="abc123",
):
resolved = srv._verify_role_mutation_workspace("prgs")
self.assertEqual(resolved, os.path.realpath(MCP_PROCESS_ROOT)) self.assertEqual(resolved, os.path.realpath(MCP_PROCESS_ROOT))