fix(mcp): add sanctioned gitea_edit_issue and a doc/registry drift guard (Closes #781)

The gitea-workflow documentation named a gitea_edit_issue tool that no
namespace had ever registered. There was no sanctioned MCP path to change an
issue title or body at all: the only edit tool, gitea_edit_pr, PATCHes the
pull-request endpoint. An authorized body correction on issue #780 therefore
had to be recorded as a discussion comment instead.

Add edit_issue.py as the authoritative rule and gitea_edit_issue as the tool
built on it:

- Only the fields the caller names are sent, so labels, state, assignee, and
  milestone cannot be overwritten from a stale read.
- A pull-request number is refused. Gitea serves pull requests from the same
  /issues/{n} collection, so without that check the issue path would quietly
  become a second, ungated PR edit path. gitea_edit_pr stays PR-only.
- Structurally invalid requests raise before any credential or network work;
  a request that would change nothing is reported as an explicit no-op with a
  next action rather than a silent success.
- Read-after-write proves the applied title/body and proves that state,
  labels, assignees, and milestone did not move. Transport failures on the
  pre-read, the PATCH, and the read-back are each reported, redacted, with
  the correct performed/verified state.

Gates match every other issue mutation: profile permission via the shared
capability map (resolver task edit_issue, gitea.issue.comment), preflight
purity, branches worktree validation, anti-stomp inventory membership, and
audited mutation.

Fix the drift that hid this. docs/mcp-tool-inventory.md is now the canonical
registered-tool list, and mcp_tool_inventory.py compares it to the live
registry in both directions, plus checks that every tool named under skills/
is registered. The new guard immediately found a second instance of the same
defect: gitea_record_pre_review_command had lost its @mcp.tool() decorator
while the canonical review workflow still instructed reviewers to call it, so
that registration is restored.

Validation:
PASSED: venv/bin/python -m pytest tests/test_issue_781_edit_issue_tool.py -s -q
  — 50 passed
FAILED: venv/bin/python -m pytest -s -q — 4095 passed, 11 failed, 6 skipped.
  The identical 11 tests fail on clean master 8e149e6 with no changes applied
  (254 passed, 11 failed across those five files), so they are pre-existing
  and proven by a baseline run rather than asserted.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-21 16:52:23 -04:00
co-authored by Claude Opus 4.8
parent 8e149e6cfa
commit a002864a06
9 changed files with 1473 additions and 0 deletions
+176
View File
@@ -2123,6 +2123,7 @@ def _seed_session_context(
import issue_work_duplicate_gate # noqa: E402
import issue_workflow_labels # noqa: E402
import terminal_pr_label_cleanup # noqa: E402 # #780 status:pr-open terminal rule
import edit_issue # noqa: E402 # #781 issue title/body edit rule
import reviewer_pr_lease # noqa: E402
import post_merge_moot_lease_gate # noqa: E402 # #745 reconciler cleanup gate
import merger_lease_adoption # noqa: E402
@@ -11764,6 +11765,175 @@ def gitea_close_issue(
}
@mcp.tool()
def gitea_edit_issue(
issue_number: int,
title: str | None = None,
body: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Edit an issue's title, body, or both through the sanctioned path (#781).
This is the only MCP path that changes issue content. It PATCHes the issue
endpoint and never the pull-request endpoint: ``gitea_edit_pr`` stays
pull-request-only, and a pull-request number passed here is refused rather
than edited, even though Gitea serves pull requests from ``/issues/{n}``.
Only the fields named in the call are sent, so labels, state, assignee, and
milestone cannot be overwritten by a stale read. The edit is proven by
read-after-write: the tool re-reads the issue and fails closed if the stored
title/body differ from what was requested, or if any unspecified field moved.
Args:
issue_number: The issue number to edit.
title: New issue title. Omit to leave the title unchanged.
body: New issue body. Omit to leave the body unchanged; pass ''
deliberately to clear it.
remote: Known instance 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
worktree_path: Optional branches worktree to verify before mutation.
Returns:
dict with 'success', 'performed', the 'applied' content, and a
'read_after_write' proof carrying the preserved-field comparison. A
no-op, a pull-request target, a permission block, or a failed
verification all return 'performed'/'success' False with 'reasons' and
a 'safe_next_action'. Invalid input raises ValueError before any
credential or network work.
"""
# Validate BEFORE auth/profile/API setup: a malformed or empty request is a
# pure input error and must not depend on credentials or configuration.
edit_issue.validate_edit_request(title=title, body=body)
blocked = _profile_permission_block(
task_capability_map.required_permission("edit_issue"),
issue_number=issue_number,
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
verify_preflight_purity(
remote,
worktree_path=worktree_path,
task="edit_issue",
org=org,
repo=repo,
)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
try:
current = api_request("GET", url, auth)
except Exception as exc: # noqa: BLE001 — redact before surfacing
return {
"success": False,
"performed": False,
"issue_number": issue_number,
"reasons": [
f"could not read issue #{issue_number} before editing: "
f"{_redact(str(exc))}"
],
"safe_next_action": (
f"Confirm issue #{issue_number} exists in {o}/{r} with "
"gitea_view_issue, then retry the edit."
),
}
target = edit_issue.assess_issue_target(current, issue_number=issue_number)
if not target["is_issue"]:
return {
"success": False,
"performed": False,
"blocked": True,
"issue_number": issue_number,
"target": target,
"reasons": target["reasons"],
"safe_next_action": target["safe_next_action"],
}
plan = edit_issue.plan_issue_edit(
current, title=title, body=body, issue_number=issue_number
)
if plan["no_op"]:
return {
"success": False,
"performed": False,
"no_op": True,
"issue_number": issue_number,
"requested_fields": plan["requested_fields"],
"unchanged_fields": plan["unchanged_fields"],
"reasons": plan["reasons"],
"safe_next_action": plan["safe_next_action"],
}
try:
with _audited("edit_issue", host=h, remote=remote, org=o, repo=r,
issue_number=issue_number,
request_metadata={"fields": plan["requested_fields"]}):
api_request("PATCH", url, auth, plan["payload"])
except Exception as exc: # noqa: BLE001 — redact before surfacing
return {
"success": False,
"performed": False,
"issue_number": issue_number,
"requested_fields": plan["requested_fields"],
"reasons": [f"issue edit failed: {_redact(str(exc))}"],
"safe_next_action": (
f"Re-read issue #{issue_number} with gitea_view_issue to confirm "
"nothing was applied, then retry the edit."
),
}
# Read-after-write (#781): the PATCH response is not proof. Re-read the
# issue so an unapplied edit, or a preserved field that moved, fails closed.
try:
observed = api_request("GET", url, auth)
except Exception as exc: # noqa: BLE001 — redact before surfacing
return {
"success": False,
"performed": True,
"verified": False,
"issue_number": issue_number,
"requested_fields": plan["requested_fields"],
"reasons": [
f"issue edit read-back failed: {_redact(str(exc))}"
],
"safe_next_action": (
f"Re-read issue #{issue_number} with gitea_view_issue and confirm "
"the applied content before relying on this edit."
),
}
verification = edit_issue.verify_issue_edit(observed, plan=plan)
result = {
"success": verification["verified"],
"performed": True,
"verified": verification["verified"],
"issue_number": issue_number,
"requested_fields": plan["requested_fields"],
"changed_fields": sorted(plan["changes"]),
"applied": verification["applied"],
"read_after_write": verification,
"preserved_intact": verification["preserved_intact"],
"reasons": verification["reasons"],
"safe_next_action": verification["safe_next_action"],
}
return _with_optional_url(result, observed.get("html_url"))
@mcp.tool()
def gitea_cleanup_terminal_pr_labels(
issue_numbers: list[int],
@@ -16002,6 +16172,12 @@ def gitea_assess_master_parity(
if parity["stale"] and enforced:
out["report"] = master_parity_gate.parity_report(parity)
return out
# #781: documented in the canonical review workflow as a tool reviewers call
# before workflow load, but the registration decorator had been lost, so the
# documented inventory named something no namespace could reach.
@mcp.tool()
def gitea_record_pre_review_command(
command: str,
cwd: str | None = None,