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
197 lines
6.9 KiB
Python
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)
|