Files
Gitea-Tools/mcp_tool_inventory.py
T
sysadminandClaude Opus 5 92615f474b feat(control-plane): add authoritative native MCP fleet inventory (#949)
Every pre-existing runtime surface is per-process. gitea_get_runtime_context
and gitea_assess_master_parity describe only the server answering the call,
and gitea_assess_mcp_namespace_health accepts process, probe_result and
registered_tools from the caller, so it cannot constrain the caller. The
control-plane sessions table records allocator task sessions, not server
processes. Five self-reports of one revision therefore never proved that five
processes exist, that no sixth exists, or that all five share one client
cohort.

Add gitea_assess_fleet_inventory, a strictly read-only capability that takes
no evidence parameters. It combines two sources that must agree:

- a control-plane runtime registry row each server writes about itself,
  from the official entrypoint, immediately after the native transport bind;
- a process observation the answering server performs, never the caller.

A member is running only when both agree and the observed process predates
its registration, so configuration alone never counts and a recycled PID
cannot impersonate an exited server. Classification is a pure function of the
snapshot, so gitea-controller and gitea-reconciler return the same verdict.

Missing, duplicate, unexpected, stale and unregistered members are reported
in separate fields rather than collapsed into one error, because each needs a
different operator action. single_cohort is derived only from recorded cohort
identity and stays null when unknown, so matching revisions never establish a
cohort. Any unreadable registry, unavailable listing, unregistered process,
unknown cohort or unknown revision sets inventory_complete and
mutation_gate_satisfied false with a specific blocked_reason.

The capability performs no restart, reconnect, drain, lease mutation, issue
mutation or process termination, never terminates a duplicate, and never
manufactures restart evidence. The only signal sent is signal 0.

Also resolves the fleet namespace from the expected roster:
role_namespace_gate.infer_mcp_namespace recognises only author and reviewer
and echoes the profile name otherwise, which would have mislabelled the
controller, merger and reconciler members. Widening that shared helper is
controller role-metadata work owned by #950 and is deliberately not done here.

Schema v5 to v6 is additive and idempotent: one new table, no existing table,
tool signature or result field changed. Two control-plane tests that pinned
the schema version to the literal 5 now assert against SCHEMA_VERSION.

#950 (controller role metadata), #951 (restart receipts) and #952 (stale-lease
consistency) remain separate and untouched.

Tests: 118 new across tests/test_mcp_fleet_inventory.py,
tests/test_control_plane_db_server_runtimes.py and
tests/test_fleet_inventory_tool.py, covering every acceptance criterion
including the multi-LLM duplicate-server regression that motivated the issue.

Closes #949

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NBaQKcRRKeKbZuhk72MhEf
2026-07-27 23:25:45 -04:00

197 lines
6.9 KiB
Python

"""Documented-vs-registered MCP tool inventory guard (#781).
The workflow documentation named a ``gitea_edit_issue`` tool that no namespace
had ever registered. Nothing compared the two lists, so an actor could plan a
mutation against a tool that did not exist and only discover it at execution
time — after the work was already scoped around it.
This module is that comparison, in two directions:
- :func:`assess_inventory_drift` compares the canonical inventory documented in
``docs/mcp-tool-inventory.md`` against the tools actually registered on the
MCP server. Either list drifting fails the guard, so a new tool must be
documented and a removed tool must be undocumented in the same change.
- :func:`assess_doc_references` catches the original defect directly: any tool
name a workflow/skill document tells an actor to call must be registered.
Module and script names legitimately appear in the same prose (``gitea_auth``,
``offline_mcp_runner``), so :data:`NON_TOOL_IDENTIFIERS` names the known
non-tool identifiers explicitly rather than loosening the pattern.
This module performs no I/O — callers own reading the files.
"""
from __future__ import annotations
import re
from typing import Any, Iterable, Mapping
#: Canonical documented inventory, relative to the repository root.
INVENTORY_DOC_PATH = "docs/mcp-tool-inventory.md"
#: Delimiters around the generated inventory list in the doc.
INVENTORY_BEGIN_MARKER = "<!-- BEGIN REGISTERED TOOL INVENTORY -->"
INVENTORY_END_MARKER = "<!-- END REGISTERED TOOL INVENTORY -->"
#: Backticked identifiers that look like tool names but are modules/scripts.
#: Every entry is a real file in this repository, not an MCP tool.
NON_TOOL_IDENTIFIERS: frozenset[str] = frozenset(
{
"gitea_auth",
"gitea_config",
"gitea_mcp_server",
"mcp_fleet_inventory",
"mcp_server",
"offline_mcp_helper",
"offline_mcp_runner",
}
)
#: Prefixes that mark an identifier as a candidate MCP tool name.
TOOL_NAME_PREFIXES: tuple[str, ...] = ("gitea_", "mcp_")
_INVENTORY_ENTRY = re.compile(r"^-\s+`([A-Za-z_][A-Za-z0-9_]*)`")
_BACKTICKED = re.compile(r"`([A-Za-z_][A-Za-z0-9_]*)`")
def parse_documented_inventory(text: str) -> list[str]:
"""Return the tool names listed between the inventory markers.
Raises ``ValueError`` when the markers are missing or out of order, so a
mangled document fails the guard instead of silently documenting nothing.
"""
start = text.find(INVENTORY_BEGIN_MARKER)
end = text.find(INVENTORY_END_MARKER)
if start == -1 or end == -1 or end < start:
raise ValueError(
f"{INVENTORY_DOC_PATH} must contain "
f"'{INVENTORY_BEGIN_MARKER}' followed by "
f"'{INVENTORY_END_MARKER}' (fail closed)."
)
block = text[start + len(INVENTORY_BEGIN_MARKER) : end]
names: list[str] = []
for line in block.splitlines():
match = _INVENTORY_ENTRY.match(line.strip())
if match:
names.append(match.group(1))
return names
def looks_like_tool_name(identifier: str) -> bool:
"""Return whether a backticked identifier is a candidate tool name."""
if identifier in NON_TOOL_IDENTIFIERS:
return False
return identifier.startswith(TOOL_NAME_PREFIXES)
def extract_tool_references(text: str) -> set[str]:
"""Return candidate tool names a document tells an actor to call."""
return {
name
for name in _BACKTICKED.findall(text)
if looks_like_tool_name(name)
}
def assess_inventory_drift(
documented: Iterable[str],
registered: Iterable[str],
) -> dict[str, Any]:
"""Compare the documented inventory against the registered tool set."""
documented_list = list(documented)
documented_set = set(documented_list)
registered_set = set(registered)
duplicates = sorted(
{name for name in documented_list if documented_list.count(name) > 1}
)
documented_not_registered = sorted(documented_set - registered_set)
registered_not_documented = sorted(registered_set - documented_set)
unsorted = documented_list != sorted(documented_list)
reasons: list[str] = []
if documented_not_registered:
reasons.append(
"documented but not registered: "
+ ", ".join(documented_not_registered)
)
if registered_not_documented:
reasons.append(
"registered but not documented: "
+ ", ".join(registered_not_documented)
)
if duplicates:
reasons.append("listed more than once: " + ", ".join(duplicates))
if unsorted:
reasons.append("inventory entries are not in sorted order")
in_sync = not reasons
return {
"in_sync": in_sync,
"documented_count": len(documented_set),
"registered_count": len(registered_set),
"documented_not_registered": documented_not_registered,
"registered_not_documented": registered_not_documented,
"duplicates": duplicates,
"sorted": not unsorted,
"reasons": reasons,
"safe_next_action": (
""
if in_sync
else (
f"Update {INVENTORY_DOC_PATH} so the block between the "
"inventory markers lists exactly the registered tools, sorted, "
"one '- `tool_name`' entry per line."
)
),
}
def assess_doc_references(
references: Mapping[str, Iterable[str]],
registered: Iterable[str],
) -> dict[str, Any]:
"""Verify every tool a document names is actually registered.
*references* maps a document path to the candidate tool names it mentions.
"""
registered_set = set(registered)
unregistered: list[dict[str, Any]] = []
checked = 0
for path, names in sorted(references.items()):
for name in sorted(set(names)):
checked += 1
if name not in registered_set:
unregistered.append({"document": path, "tool": name})
clean = not unregistered
reasons = [
f"{entry['document']} documents '{entry['tool']}', "
"which no namespace registers"
for entry in unregistered
]
return {
"clean": clean,
"checked_count": checked,
"unregistered": unregistered,
"reasons": reasons,
"safe_next_action": (
""
if clean
else (
"Either register the named tool with @mcp.tool() or correct the "
"document. Documentation must never name a tool an actor cannot "
"reach. If the identifier is a module or script rather than a "
"tool, add it to mcp_tool_inventory.NON_TOOL_IDENTIFIERS."
)
),
}
def render_inventory_block(registered: Iterable[str]) -> str:
"""Render the marker-delimited inventory block for the documentation."""
lines = [INVENTORY_BEGIN_MARKER, ""]
lines.extend(f"- `{name}`" for name in sorted(set(registered)))
lines.extend(["", INVENTORY_END_MARKER])
return "\n".join(lines)