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
338 lines
14 KiB
Python
338 lines
14 KiB
Python
"""Native exposure of the fleet inventory capability (#949).
|
|
|
|
Covers the parts the pure classifier cannot: that the tool is registered and
|
|
documented, that it is gated on ``gitea.read`` rather than a role, that it
|
|
accepts no caller-supplied evidence, that it fails closed on an unreadable
|
|
registry, and that the workflow documentation explains gate consumption.
|
|
"""
|
|
|
|
import inspect
|
|
import os
|
|
import pathlib
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
import gitea_mcp_server
|
|
import mcp_fleet_inventory as mfi
|
|
import task_capability_map
|
|
|
|
|
|
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
TOOL_NAME = "gitea_assess_fleet_inventory"
|
|
|
|
|
|
class TestCapabilityMapExposure(unittest.TestCase):
|
|
"""AC14: the invariant is provable through sanctioned native calls."""
|
|
|
|
def test_task_resolves_to_a_read_permission(self):
|
|
self.assertEqual(
|
|
task_capability_map.required_permission("assess_fleet_inventory"),
|
|
"gitea.read",
|
|
)
|
|
self.assertEqual(
|
|
task_capability_map.required_permission(TOOL_NAME), "gitea.read"
|
|
)
|
|
|
|
def test_task_and_tool_keys_agree(self):
|
|
self.assertEqual(
|
|
task_capability_map.TASK_CAPABILITY_MAP["assess_fleet_inventory"],
|
|
task_capability_map.TASK_CAPABILITY_MAP[TOOL_NAME],
|
|
)
|
|
|
|
def test_task_is_not_role_exclusive(self):
|
|
"""Controller and reconciler both need it; so does any read-only gate."""
|
|
self.assertNotIn(
|
|
"assess_fleet_inventory", task_capability_map.ROLE_EXCLUSIVE_TASKS
|
|
)
|
|
self.assertNotIn(TOOL_NAME, task_capability_map.ROLE_EXCLUSIVE_TASKS)
|
|
|
|
def test_task_is_not_registered_as_an_issue_mutation(self):
|
|
self.assertNotIn(TOOL_NAME, task_capability_map.ISSUE_MUTATION_TOOL_TASKS)
|
|
|
|
def test_unknown_task_still_fails_closed(self):
|
|
with self.assertRaises(KeyError):
|
|
task_capability_map.required_permission("assess_fleet_inventory_typo")
|
|
|
|
|
|
class TestToolRegistration(unittest.TestCase):
|
|
def test_tool_is_registered_on_the_server(self):
|
|
self.assertTrue(hasattr(gitea_mcp_server, TOOL_NAME))
|
|
|
|
def test_tool_accepts_no_caller_supplied_evidence(self):
|
|
"""The defect #949 names: evidence parameters the caller controls."""
|
|
signature = inspect.signature(gitea_mcp_server.gitea_assess_fleet_inventory)
|
|
self.assertEqual(sorted(signature.parameters), ["host", "org", "remote", "repo"])
|
|
for forbidden in ("process", "probe_result", "registered_tools", "processes"):
|
|
self.assertNotIn(forbidden, signature.parameters)
|
|
|
|
def test_tool_is_documented_in_the_canonical_inventory(self):
|
|
doc = (REPO_ROOT / "docs" / "mcp-tool-inventory.md").read_text()
|
|
self.assertIn(f"`{TOOL_NAME}`", doc)
|
|
|
|
def test_docstring_states_the_read_only_guarantee(self):
|
|
doc = (gitea_mcp_server.gitea_assess_fleet_inventory.__doc__ or "").lower()
|
|
self.assertIn("read-only", doc)
|
|
self.assertIn("fails closed", doc)
|
|
|
|
|
|
class TestNamespaceResolution(unittest.TestCase):
|
|
"""Every configured profile must resolve to its real fleet namespace.
|
|
|
|
``role_namespace_gate.infer_mcp_namespace`` recognises only author and
|
|
reviewer and echoes the profile name for the rest, which would label the
|
|
controller, merger, and reconciler members with namespaces that do not
|
|
exist — and then flag all three as role mismatches.
|
|
"""
|
|
|
|
def test_every_expected_profile_maps_to_its_namespace(self):
|
|
for entry in mfi.EXPECTED_PRGS_FLEET:
|
|
self.assertEqual(
|
|
mfi.namespace_for_profile(entry["profile"]),
|
|
entry["namespace"],
|
|
entry["profile"],
|
|
)
|
|
|
|
def test_server_helper_agrees_with_the_roster(self):
|
|
for entry in mfi.EXPECTED_PRGS_FLEET:
|
|
self.assertEqual(
|
|
gitea_mcp_server._fleet_namespace_for_profile(entry["profile"]),
|
|
entry["namespace"],
|
|
entry["profile"],
|
|
)
|
|
|
|
def test_unknown_profile_falls_back_to_the_supplied_default(self):
|
|
self.assertEqual(
|
|
mfi.namespace_for_profile("prgs-shadow", default="gitea-shadow"),
|
|
"gitea-shadow",
|
|
)
|
|
|
|
def test_unknown_profile_without_a_default_returns_the_profile(self):
|
|
self.assertEqual(mfi.namespace_for_profile("prgs-shadow"), "prgs-shadow")
|
|
|
|
def test_registered_row_uses_the_roster_namespace(self):
|
|
fake_db = mock.Mock()
|
|
with mock.patch.object(
|
|
gitea_mcp_server, "_control_plane_db_or_error", return_value=(fake_db, [])
|
|
), mock.patch.object(
|
|
gitea_mcp_server, "get_profile", return_value={"allowed_operations": []}
|
|
), mock.patch.object(
|
|
gitea_mcp_server.gitea_config,
|
|
"selected_profile_name",
|
|
return_value="prgs-reconciler",
|
|
):
|
|
record = gitea_mcp_server._register_fleet_runtime(transport="stdio")
|
|
self.assertEqual(record["namespace"], "gitea-reconciler")
|
|
|
|
|
|
class TestToolBehavior(unittest.TestCase):
|
|
"""The tool wires the registry and the process scan into the classifier."""
|
|
|
|
@staticmethod
|
|
def _healthy_rows():
|
|
return [
|
|
{
|
|
"runtime_id": f"{entry['namespace']}:{43000 + index}:boot",
|
|
"namespace": entry["namespace"],
|
|
"profile": entry["profile"],
|
|
"role": entry["role"],
|
|
"remote": "prgs",
|
|
"org": "Scaled-Tech-Consulting",
|
|
"repo": "Gitea-Tools",
|
|
"repository_root": "/checkout/Gitea-Tools",
|
|
"pid": 43000 + index,
|
|
"cohort_id": "ppid:40990",
|
|
"cohort_source": "parent_process",
|
|
"client_provenance": "client_managed",
|
|
"boot_id": "boot",
|
|
"startup_head": "82d71b77028a7abd4f8ab4a4e4d89658a187f73d",
|
|
"daemon_start_head": "82d71b77028a7abd4f8ab4a4e4d89658a187f73d",
|
|
"transport": "stdio",
|
|
"registered_at": "2026-07-28T02:00:00Z",
|
|
"last_heartbeat_at": "2026-07-28T02:00:00Z",
|
|
"status": "running",
|
|
}
|
|
for index, entry in enumerate(mfi.EXPECTED_PRGS_FLEET)
|
|
]
|
|
|
|
def _call(self, rows, *, scan=None, db=None, db_errors=None):
|
|
fake_db = db
|
|
if fake_db is None and db_errors is None:
|
|
fake_db = mock.Mock()
|
|
fake_db.list_mcp_server_runtimes.return_value = rows
|
|
scan = scan or {
|
|
"available": True,
|
|
"processes": [
|
|
{"pid": r["pid"], "started_at": "2026-07-28T01:00:00Z"} for r in rows
|
|
],
|
|
"reason": None,
|
|
}
|
|
with mock.patch.object(
|
|
gitea_mcp_server, "_profile_operation_gate", return_value=[]
|
|
), mock.patch.object(
|
|
gitea_mcp_server,
|
|
"_control_plane_db_or_error",
|
|
return_value=(fake_db, db_errors or []),
|
|
), mock.patch.object(
|
|
gitea_mcp_server, "_active_profile_name", return_value="prgs-controller"
|
|
), mock.patch.object(
|
|
mfi, "scan_mcp_server_processes", return_value=scan
|
|
), mock.patch.object(
|
|
mfi, "probe_pid_alive", return_value=True
|
|
):
|
|
return gitea_mcp_server.gitea_assess_fleet_inventory(
|
|
remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools"
|
|
)
|
|
|
|
def test_healthy_fleet_satisfies_the_gate_through_the_tool(self):
|
|
result = self._call(self._healthy_rows())
|
|
self.assertTrue(result["inventory_complete"])
|
|
self.assertTrue(result["mutation_gate_satisfied"])
|
|
self.assertEqual(result["running_member_count"], 5)
|
|
self.assertEqual(result["mutations_performed"], [])
|
|
|
|
def test_tool_reports_the_expected_repository_binding(self):
|
|
result = self._call(self._healthy_rows())
|
|
self.assertEqual(
|
|
result["expected_repository_binding"],
|
|
{"remote": "prgs", "org": "Scaled-Tech-Consulting", "repo": "Gitea-Tools"},
|
|
)
|
|
|
|
def test_tool_reads_only_running_rows(self):
|
|
rows = self._healthy_rows()
|
|
fake_db = mock.Mock()
|
|
fake_db.list_mcp_server_runtimes.return_value = rows
|
|
self._call(rows, db=fake_db)
|
|
fake_db.list_mcp_server_runtimes.assert_called_once_with(statuses=("running",))
|
|
|
|
def test_tool_never_writes_to_the_registry(self):
|
|
rows = self._healthy_rows()
|
|
fake_db = mock.Mock()
|
|
fake_db.list_mcp_server_runtimes.return_value = rows
|
|
self._call(rows, db=fake_db)
|
|
fake_db.register_mcp_server_runtime.assert_not_called()
|
|
fake_db.heartbeat_mcp_server_runtime.assert_not_called()
|
|
fake_db.mark_mcp_server_runtime_stopped.assert_not_called()
|
|
|
|
def test_unavailable_control_plane_fails_closed(self):
|
|
result = self._call([], db_errors=["control-plane DB substrate unavailable"])
|
|
self.assertFalse(result["inventory_complete"])
|
|
self.assertFalse(result["mutation_gate_satisfied"])
|
|
self.assertFalse(result["registry"]["available"])
|
|
self.assertIn("unavailable", result["blocked_reason"])
|
|
|
|
def test_registry_read_failure_fails_closed(self):
|
|
rows = self._healthy_rows()
|
|
fake_db = mock.Mock()
|
|
fake_db.list_mcp_server_runtimes.side_effect = RuntimeError("db locked")
|
|
result = self._call(rows, db=fake_db)
|
|
self.assertFalse(result["inventory_complete"])
|
|
self.assertFalse(result["mutation_gate_satisfied"])
|
|
self.assertIn("could not be read", result["registry"]["error"])
|
|
|
|
def test_missing_read_permission_blocks_without_touching_the_registry(self):
|
|
fake_db = mock.Mock()
|
|
with mock.patch.object(
|
|
gitea_mcp_server,
|
|
"_profile_operation_gate",
|
|
return_value=["profile may not read"],
|
|
), mock.patch.object(
|
|
gitea_mcp_server, "_control_plane_db_or_error", return_value=(fake_db, [])
|
|
):
|
|
result = gitea_mcp_server.gitea_assess_fleet_inventory(remote="prgs")
|
|
self.assertFalse(result["success"])
|
|
self.assertFalse(result["mutation_gate_satisfied"])
|
|
self.assertIn("permission_report", result)
|
|
self.assertEqual(result["mutations_performed"], [])
|
|
fake_db.list_mcp_server_runtimes.assert_not_called()
|
|
|
|
def test_answering_namespace_is_reported(self):
|
|
result = self._call(self._healthy_rows())
|
|
self.assertEqual(result["answering_namespace"], "gitea-controller")
|
|
|
|
def test_summary_is_present(self):
|
|
result = self._call(self._healthy_rows())
|
|
self.assertIn("5 of 5", result["summary"])
|
|
|
|
|
|
class TestStartupRegistration(unittest.TestCase):
|
|
"""The registry row is written by the process it describes."""
|
|
|
|
def test_registration_helper_exists_on_the_entrypoint_module(self):
|
|
self.assertTrue(hasattr(gitea_mcp_server, "_register_fleet_runtime"))
|
|
|
|
def test_registration_writes_one_row_for_this_process(self):
|
|
fake_db = mock.Mock()
|
|
with mock.patch.object(
|
|
gitea_mcp_server, "_control_plane_db_or_error", return_value=(fake_db, [])
|
|
), mock.patch.object(
|
|
gitea_mcp_server, "get_profile", return_value={"allowed_operations": []}
|
|
):
|
|
record = gitea_mcp_server._register_fleet_runtime(transport="stdio")
|
|
self.assertIsNotNone(record)
|
|
fake_db.register_mcp_server_runtime.assert_called_once()
|
|
written = fake_db.register_mcp_server_runtime.call_args.args[0]
|
|
self.assertEqual(written["pid"], os.getpid())
|
|
self.assertEqual(written["transport"], "stdio")
|
|
|
|
def test_registration_failure_never_blocks_startup(self):
|
|
with mock.patch.object(
|
|
gitea_mcp_server,
|
|
"_control_plane_db_or_error",
|
|
side_effect=RuntimeError("boom"),
|
|
):
|
|
self.assertIsNone(gitea_mcp_server._register_fleet_runtime())
|
|
|
|
def test_registration_is_skipped_when_the_control_plane_is_unavailable(self):
|
|
with mock.patch.object(
|
|
gitea_mcp_server,
|
|
"_control_plane_db_or_error",
|
|
return_value=(None, ["unavailable"]),
|
|
):
|
|
self.assertIsNone(gitea_mcp_server._register_fleet_runtime())
|
|
|
|
def test_entrypoint_registers_after_binding_native_transport(self):
|
|
"""Order matters: only a transport-bound process may claim a row."""
|
|
source = (REPO_ROOT / "gitea_mcp_server.py").read_text()
|
|
bind_at = source.index('bind_native_mcp_transport(transport="stdio")')
|
|
register_at = source.index('_register_fleet_runtime(transport="stdio")')
|
|
run_at = source.index('mcp.run(transport="stdio")')
|
|
self.assertLess(bind_at, register_at)
|
|
self.assertLess(register_at, run_at)
|
|
|
|
|
|
class TestWorkflowDocumentation(unittest.TestCase):
|
|
"""AC13: documentation explains how the gates consume the result."""
|
|
|
|
def setUp(self):
|
|
self.doc = (REPO_ROOT / "docs" / "mcp-fleet-inventory.md").read_text()
|
|
self.skill = (
|
|
REPO_ROOT / "skills" / "llm-project-workflow" / "SKILL.md"
|
|
).read_text()
|
|
|
|
def test_dedicated_document_exists(self):
|
|
self.assertIn("# Authoritative MCP fleet inventory", self.doc)
|
|
|
|
def test_document_names_both_consuming_namespaces(self):
|
|
self.assertIn("gitea-controller", self.doc)
|
|
self.assertIn("gitea-reconciler", self.doc)
|
|
|
|
def test_document_explains_gate_consumption(self):
|
|
self.assertIn("mutation_gate_satisfied", self.doc)
|
|
self.assertIn("blocked_reason", self.doc)
|
|
|
|
def test_document_states_the_non_inferences(self):
|
|
self.assertIn("Configuration is not existence", self.doc)
|
|
self.assertIn("Matching revisions are not a cohort", self.doc)
|
|
|
|
def test_document_preserves_the_neighbouring_issue_boundaries(self):
|
|
for issue in ("#950", "#951", "#952"):
|
|
self.assertIn(issue, self.doc)
|
|
|
|
def test_canonical_workflow_skill_links_the_document(self):
|
|
self.assertIn("docs/mcp-fleet-inventory.md", self.skill)
|
|
self.assertIn(TOOL_NAME, self.skill)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|