feat(mcp): expose sanctioned Codex MCP reconnect request (Closes #678)
Add gitea_request_mcp_reconnect as a report-only callable surface so Codex and other agent hosts can request host/IDE reconnect with typed blockers and exact UI steps. Never kills processes or edits config. Co-Authored-By: Grok 4.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
"""Sanctioned MCP client reconnect request surface for Codex/LLM sessions (#678).
|
||||
|
||||
Codex and other agent hosts can detect stale or closed Gitea MCP runtimes, but
|
||||
the host owns the transport. This module never restarts, kills, or reloads a
|
||||
daemon. It builds:
|
||||
|
||||
1. A **callable reconnect request** result agents can invoke via
|
||||
``gitea_request_mcp_reconnect`` (report-only, side-effect free).
|
||||
2. A **typed blocker** with exact operator UI steps when recovery must be
|
||||
performed by the host/operator.
|
||||
|
||||
Forbidden recovery paths (must never be recommended):
|
||||
|
||||
* ``pkill`` / ``kill`` / ``killall`` of MCP daemons
|
||||
* ``touch`` / mtime config reload hacks
|
||||
* ``.env`` or MCP config edits as recovery
|
||||
* session-state file edits
|
||||
* raw Gitea API / direct server-import fallbacks
|
||||
|
||||
After the operator reconnects, workflows restart from identity / runtime /
|
||||
capability preflight (``gitea_whoami`` → ``gitea_resolve_task_capability`` →
|
||||
task).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
# --- Reason vocabulary -------------------------------------------------------
|
||||
|
||||
REASON_STALE_RUNTIME = "stale-runtime"
|
||||
REASON_TRANSPORT_EOF = "transport_eof"
|
||||
REASON_MISSING_NAMESPACE = "missing_namespace"
|
||||
REASON_NOT_REQUIRED = "not_required"
|
||||
REASON_UNSPECIFIED = "unspecified"
|
||||
|
||||
VALID_REASONS = frozenset(
|
||||
{
|
||||
REASON_STALE_RUNTIME,
|
||||
REASON_TRANSPORT_EOF,
|
||||
REASON_MISSING_NAMESPACE,
|
||||
REASON_NOT_REQUIRED,
|
||||
REASON_UNSPECIFIED,
|
||||
}
|
||||
)
|
||||
|
||||
# Boundary statuses reported to callers (match review_workflow_boundary style).
|
||||
BOUNDARY_CLEAN = "clean"
|
||||
BOUNDARY_MISMATCH = "mismatch"
|
||||
BOUNDARY_STALE = "stale"
|
||||
BOUNDARY_UNKNOWN = "unknown"
|
||||
|
||||
# Typed blocker kinds
|
||||
BLOCKER_OPERATOR_RECONNECT = "operator_mcp_reconnect_required"
|
||||
BLOCKER_NONE = "none"
|
||||
|
||||
FORBIDDEN_RECOVERY_PATHS: tuple[str, ...] = (
|
||||
"pkill / kill / killall of mcp_server.py, gitea_mcp_server, or broad python sweeps",
|
||||
"touch / mtime-based MCP config reload hacks",
|
||||
".env edits as recovery",
|
||||
"MCP config file edits as recovery",
|
||||
"session-state file edits as recovery",
|
||||
"raw Gitea API or direct MCP server-import fallbacks",
|
||||
)
|
||||
|
||||
# Client-specific operator UI steps. Keep Codex first (issue title surface).
|
||||
OPERATOR_UI_STEPS: dict[str, tuple[str, ...]] = {
|
||||
"codex": (
|
||||
"In Codex, open the MCP / Developer tools panel for this workspace.",
|
||||
"Locate the named Gitea MCP server entry (namespace) that needs reconnect "
|
||||
"(e.g. gitea-author, gitea-reviewer, gitea-merger, gitea-tools, "
|
||||
"gitea-controller, gitea-reconciler).",
|
||||
"Click 'Reload Developer Tools' or the server reconnect/reload control "
|
||||
"for that entry so the client spawns a fresh MCP subprocess.",
|
||||
"If per-server reconnect is unavailable, fully restart the Codex client "
|
||||
"(quit and relaunch) so all MCP namespaces reattach.",
|
||||
"After reconnect, rerun the blocked workflow from preflight: "
|
||||
"gitea_whoami → gitea_resolve_task_capability → the original task. "
|
||||
"Do not resume mid-mutation.",
|
||||
),
|
||||
"claude_code": (
|
||||
"Run `/mcp` (or open the MCP servers UI) in Claude Code.",
|
||||
"Reconnect the affected gitea-* server entry so the client reopens stdio.",
|
||||
"If reconnect fails, relaunch the Claude Code session entirely.",
|
||||
"After reconnect, restart the workflow from gitea_whoami → "
|
||||
"gitea_resolve_task_capability → task.",
|
||||
),
|
||||
"generic": (
|
||||
"Use the host/IDE MCP reconnect or reload control for the named namespace.",
|
||||
"If no per-namespace control exists, restart the MCP client/editor.",
|
||||
"After reconnect, restart the workflow from identity/capability preflight.",
|
||||
),
|
||||
}
|
||||
|
||||
DEFAULT_CLIENT = "codex"
|
||||
|
||||
|
||||
def normalize_reason(reason: str | None) -> str:
|
||||
"""Map free-form reason strings onto the closed vocabulary."""
|
||||
raw = (reason or "").strip().lower()
|
||||
if not raw:
|
||||
return REASON_UNSPECIFIED
|
||||
if raw in VALID_REASONS:
|
||||
return raw
|
||||
text = raw.replace(" ", "_").replace("-", "_")
|
||||
aliases = {
|
||||
"stale_runtime": REASON_STALE_RUNTIME,
|
||||
"staleruntime": REASON_STALE_RUNTIME,
|
||||
"runtime_stale": REASON_STALE_RUNTIME,
|
||||
"stale": REASON_STALE_RUNTIME,
|
||||
"transport_eof": REASON_TRANSPORT_EOF,
|
||||
"transport_closed": REASON_TRANSPORT_EOF,
|
||||
"eof": REASON_TRANSPORT_EOF,
|
||||
"client_is_closing": REASON_TRANSPORT_EOF,
|
||||
"missing_namespace": REASON_MISSING_NAMESPACE,
|
||||
"namespace_missing": REASON_MISSING_NAMESPACE,
|
||||
"not_required": REASON_NOT_REQUIRED,
|
||||
"healthy": REASON_NOT_REQUIRED,
|
||||
"ok": REASON_NOT_REQUIRED,
|
||||
"unspecified": REASON_UNSPECIFIED,
|
||||
}
|
||||
if text in aliases:
|
||||
return aliases[text]
|
||||
hyphenated = text.replace("_", "-")
|
||||
if hyphenated in VALID_REASONS:
|
||||
return hyphenated
|
||||
return REASON_UNSPECIFIED
|
||||
|
||||
|
||||
def normalize_client(client: str | None) -> str:
|
||||
"""Return a known client key for operator UI steps."""
|
||||
text = (client or "").strip().lower().replace(" ", "_").replace("-", "_")
|
||||
if text in ("codex", "openai_codex", "openai"):
|
||||
return "codex"
|
||||
if text in ("claude", "claude_code", "claude_desktop", "anthropic"):
|
||||
return "claude_code"
|
||||
if text in OPERATOR_UI_STEPS:
|
||||
return text
|
||||
return DEFAULT_CLIENT
|
||||
|
||||
|
||||
def classify_boundary_status(
|
||||
*,
|
||||
startup_sha: str | None,
|
||||
current_master_sha: str | None,
|
||||
live_stale: bool | None = None,
|
||||
in_parity: bool | None = None,
|
||||
) -> str:
|
||||
"""Derive boundary_status from parity evidence."""
|
||||
if live_stale is True or in_parity is False:
|
||||
return BOUNDARY_STALE
|
||||
start = (startup_sha or "").strip().lower()
|
||||
current = (current_master_sha or "").strip().lower()
|
||||
if start and current and start != current:
|
||||
return BOUNDARY_MISMATCH
|
||||
if start and current and start == current:
|
||||
return BOUNDARY_CLEAN
|
||||
if in_parity is True:
|
||||
return BOUNDARY_CLEAN
|
||||
return BOUNDARY_UNKNOWN
|
||||
|
||||
|
||||
def operator_ui_steps(client: str | None, *, namespace: str | None = None) -> list[str]:
|
||||
"""Exact operator UI steps for the named client."""
|
||||
key = normalize_client(client)
|
||||
steps = list(OPERATOR_UI_STEPS.get(key) or OPERATOR_UI_STEPS[DEFAULT_CLIENT])
|
||||
ns = (namespace or "").strip()
|
||||
if ns:
|
||||
steps = [
|
||||
s.replace("named Gitea MCP server entry (namespace)", f"namespace '{ns}'")
|
||||
.replace("affected gitea-* server entry", f"server entry '{ns}'")
|
||||
.replace("named namespace", f"namespace '{ns}'")
|
||||
for s in steps
|
||||
]
|
||||
return steps
|
||||
|
||||
|
||||
def build_reconnect_request(
|
||||
*,
|
||||
namespace: str,
|
||||
profile: str | None = None,
|
||||
pid: int | str | None = None,
|
||||
session_id: str | None = None,
|
||||
startup_sha: str | None = None,
|
||||
current_master_sha: str | None = None,
|
||||
boundary_status: str | None = None,
|
||||
reason: str | None = None,
|
||||
client: str | None = DEFAULT_CLIENT,
|
||||
live_stale: bool | None = None,
|
||||
in_parity: bool | None = None,
|
||||
restart_required: bool | None = None,
|
||||
stop_required: bool | None = None,
|
||||
extra: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the structured reconnect-request / typed-blocker payload (#678).
|
||||
|
||||
Never mutates process, config, or session state. Always side-effect free.
|
||||
"""
|
||||
ns = (namespace or "").strip() or "unknown"
|
||||
normalized_reason = normalize_reason(reason)
|
||||
boundary = (boundary_status or "").strip() or classify_boundary_status(
|
||||
startup_sha=startup_sha,
|
||||
current_master_sha=current_master_sha,
|
||||
live_stale=live_stale,
|
||||
in_parity=in_parity,
|
||||
)
|
||||
|
||||
reconnect_needed = True
|
||||
if normalized_reason == REASON_NOT_REQUIRED and boundary == BOUNDARY_CLEAN:
|
||||
reconnect_needed = False
|
||||
if restart_required is False and stop_required is False and boundary == BOUNDARY_CLEAN:
|
||||
# Explicit healthy probe
|
||||
if normalized_reason in (REASON_NOT_REQUIRED, REASON_UNSPECIFIED):
|
||||
reconnect_needed = False
|
||||
normalized_reason = REASON_NOT_REQUIRED
|
||||
|
||||
if restart_required is True or stop_required is True:
|
||||
reconnect_needed = True
|
||||
if normalized_reason in (REASON_NOT_REQUIRED, REASON_UNSPECIFIED):
|
||||
normalized_reason = REASON_STALE_RUNTIME
|
||||
|
||||
client_key = normalize_client(client)
|
||||
steps = operator_ui_steps(client_key, namespace=ns)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"success": True,
|
||||
"read_only": True,
|
||||
"reconnect_performed": False,
|
||||
"mutation_performed": False,
|
||||
"reconnect_needed": reconnect_needed,
|
||||
"namespace": ns,
|
||||
"profile": (profile or "").strip() or None,
|
||||
"pid": pid,
|
||||
"session_id": (session_id or "").strip() or None,
|
||||
"startup_sha": (startup_sha or "").strip() or None,
|
||||
"current_master_sha": (current_master_sha or "").strip() or None,
|
||||
"boundary_status": boundary,
|
||||
"reason": normalized_reason,
|
||||
"client": client_key,
|
||||
"forbidden_recovery_paths": list(FORBIDDEN_RECOVERY_PATHS),
|
||||
"post_reconnect_preflight": [
|
||||
"gitea_whoami",
|
||||
"gitea_resolve_task_capability",
|
||||
"original_task",
|
||||
],
|
||||
"exact_safe_next_action": None,
|
||||
"blocker_kind": BLOCKER_NONE,
|
||||
"operator_ui_steps": steps,
|
||||
"typed_blocker": None,
|
||||
}
|
||||
|
||||
if reconnect_needed:
|
||||
result["blocker_kind"] = BLOCKER_OPERATOR_RECONNECT
|
||||
result["stop_required"] = True
|
||||
result["restart_required"] = True
|
||||
result["exact_safe_next_action"] = (
|
||||
f"blocker_kind={BLOCKER_OPERATOR_RECONNECT}: operator must reconnect "
|
||||
f"MCP namespace '{ns}' via the host UI (client={client_key}). "
|
||||
"Do not pkill, touch configs, edit session state, or use raw API. "
|
||||
"After reconnect, restart from gitea_whoami → "
|
||||
"gitea_resolve_task_capability → task."
|
||||
)
|
||||
result["typed_blocker"] = {
|
||||
"blocker_kind": BLOCKER_OPERATOR_RECONNECT,
|
||||
"namespaces": [ns],
|
||||
"why_reconnect_required": normalized_reason,
|
||||
"operator_ui_steps": steps,
|
||||
"client": client_key,
|
||||
"forbidden_recovery_paths": list(FORBIDDEN_RECOVERY_PATHS),
|
||||
"instruction_after_reconnect": (
|
||||
"Rerun the blocked workflow from preflight "
|
||||
"(gitea_whoami → gitea_resolve_task_capability → task). "
|
||||
"Do not continue mid-mutation from pre-reconnect state."
|
||||
),
|
||||
}
|
||||
else:
|
||||
result["stop_required"] = False
|
||||
result["restart_required"] = False
|
||||
result["exact_safe_next_action"] = (
|
||||
f"Reconnect not required for namespace '{ns}' "
|
||||
f"(boundary_status={boundary}). Proceed with the original task."
|
||||
)
|
||||
|
||||
if extra:
|
||||
for key, value in extra.items():
|
||||
if key not in result:
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def reasons_never_suggest_forbidden(text: str) -> bool:
|
||||
"""Return True when *text* does not recommend a forbidden recovery path.
|
||||
|
||||
Mentions that *ban* a path (e.g. ``Do not pkill`` / ``never edit session
|
||||
state``) are allowed. Positive recommendations such as ``use pkill`` or
|
||||
``run killall`` fail.
|
||||
"""
|
||||
import re
|
||||
|
||||
lowered = (text or "").lower()
|
||||
# Strip common ban prefixes so "do not pkill" does not trip positive checks.
|
||||
scrubbed = re.sub(
|
||||
r"\b(?:do not|don't|never|must not|forbid(?:den)?|ban(?:ned)?)\b"
|
||||
r"[^.!;\n]{0,80}",
|
||||
" ",
|
||||
lowered,
|
||||
)
|
||||
# Positive imperative / advisory forms that would tell an agent to do harm.
|
||||
positive_suggestions = (
|
||||
"use pkill",
|
||||
"run pkill",
|
||||
"try pkill",
|
||||
"pkill -f",
|
||||
"use killall",
|
||||
"run killall",
|
||||
"killall mcp",
|
||||
"use kill ",
|
||||
"run kill ",
|
||||
"touch the mcp",
|
||||
"touch mcp config",
|
||||
"utime(",
|
||||
"edit the mcp config to recover",
|
||||
"edit .env to recover",
|
||||
"import gitea_mcp_server",
|
||||
"python -c 'import gitea_mcp",
|
||||
)
|
||||
return not any(frag in scrubbed for frag in positive_suggestions)
|
||||
Reference in New Issue
Block a user