Skip to content

Commit bdb6d6b

Browse files
authored
Merge pull request openSUSE#321 from plusky/test/pin-mcp-leftovers
test(mcp): pin scattered registry/slim/session/tools behavior
2 parents 04133cb + aac347b commit bdb6d6b

4 files changed

Lines changed: 468 additions & 0 deletions
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Mutation-killing pin for the idle-sweeper's re-validation loop.
2+
3+
``tests/test_mcp_registry.py::test_sweep_spares_session_reactivated_during_the_sweep``
4+
already pins that a session re-activated mid-sweep is spared. But in that
5+
test the re-activated key is the *last* entry in the sweep's stale batch,
6+
so the "re-activated -> skip this one" ``continue`` is indistinguishable
7+
from a ``break``: either way the loop has nothing left to do. A full
8+
mutmut run found exactly that survivor (``continue`` -> ``break`` in
9+
``SessionRegistry._sweep_loop``'s re-validation branch).
10+
11+
This test mirrors that fixture but inserts a third, genuinely-idle key
12+
*after* the reactivated one in stale order, so ``continue`` (proceed to
13+
the next stale key) and ``break`` (abandon the rest of this sweep round)
14+
produce observably different outcomes: only ``continue`` still evicts
15+
the trailing idle key in the same round.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import asyncio
21+
import logging
22+
import time
23+
from pathlib import Path
24+
from unittest.mock import MagicMock
25+
26+
from mtui.mcp.main import build_session
27+
from mtui.mcp.registry import SessionRegistry
28+
29+
_LOG = logging.getLogger("test.mcp.registry.leftovers")
30+
31+
32+
def _config(tmp_path: Path) -> MagicMock:
33+
"""The minimal Config shape McpSession's constructor touches."""
34+
cfg = MagicMock()
35+
cfg.template_dir = tmp_path
36+
cfg.target_tempdir = tmp_path / "target"
37+
cfg.chdir_to_template_dir = False
38+
cfg.connection_timeout = 30
39+
cfg.session_user = "testuser"
40+
return cfg
41+
42+
43+
def _registry(tmp_path: Path, *, idle_timeout: float) -> SessionRegistry:
44+
return SessionRegistry(
45+
build_session,
46+
_config(tmp_path),
47+
_LOG,
48+
max_sessions=32,
49+
idle_timeout=idle_timeout,
50+
)
51+
52+
53+
def test_sweep_continues_past_a_reactivated_key_to_evict_a_later_idle_one(
54+
tmp_path: Path,
55+
) -> None:
56+
"""A stale batch of [k1, k2, k3] where k2 is reactivated mid-sweep.
57+
58+
k1's close is slow (buys the window needed to reactivate k2 before the
59+
sweep reaches it); k2 is refreshed during that window and must be
60+
spared (hits the "re-activated" branch); k3 is never touched and must
61+
still be evicted in the *same* sweep round. A ``continue`` -> ``break``
62+
mutation on the re-activation branch would stop the round at k2 and
63+
leave k3 (genuinely idle) un-evicted.
64+
"""
65+
reg = _registry(tmp_path, idle_timeout=4.0) # sweep interval = 2s
66+
67+
close_started = asyncio.Event()
68+
release_close = asyncio.Event()
69+
k2_closed = {"n": 0}
70+
k3_closed = {"n": 0}
71+
72+
async def driver() -> None:
73+
s1 = await reg.get_or_create("k1")
74+
s2 = await reg.get_or_create("k2")
75+
s3 = await reg.get_or_create("k3")
76+
77+
async def _slow_close() -> None:
78+
close_started.set()
79+
await release_close.wait()
80+
81+
async def _k2_close() -> None:
82+
k2_closed["n"] += 1
83+
84+
async def _k3_close() -> None:
85+
k3_closed["n"] += 1
86+
87+
s1.close = _slow_close # ty: ignore[invalid-assignment]
88+
s2.close = _k2_close # ty: ignore[invalid-assignment]
89+
s3.close = _k3_close # ty: ignore[invalid-assignment]
90+
91+
# Age all three well past the TTL so the next sweep round snapshots
92+
# stale = [k1, k2, k3] (insertion order).
93+
aged = time.monotonic() - 100
94+
reg._last_touch["k1"] = aged # noqa: SLF001
95+
reg._last_touch["k2"] = aged # noqa: SLF001
96+
reg._last_touch["k3"] = aged # noqa: SLF001
97+
98+
# Wait until the sweeper is mid-eviction of k1 (blocked in close),
99+
# then do what a live client does: grab k2, refreshing its touch.
100+
await asyncio.wait_for(close_started.wait(), timeout=10)
101+
again = await reg.get_or_create("k2")
102+
assert again is s2
103+
104+
# Let the k1 eviction finish; the sweep round proceeds to k2 (spared)
105+
# and must then continue on to k3 (genuinely idle).
106+
release_close.set()
107+
await asyncio.sleep(0.5)
108+
109+
assert "k1" not in reg._sessions # noqa: SLF001 -- genuinely idle: reaped
110+
assert "k2" in reg._sessions # noqa: SLF001 -- re-activated: spared
111+
assert k2_closed["n"] == 0
112+
assert "k3" not in reg._sessions # noqa: SLF001 -- idle: reaped despite k2's skip
113+
assert k3_closed["n"] == 1
114+
await reg.aclose()
115+
116+
asyncio.run(driver())
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Mutation-killing pins for small leftovers in :mod:`mtui.mcp.session`.
2+
3+
Two narrow gaps a full mutmut run left unasserted:
4+
5+
* ``McpSession.start_jobs``: when an explicit ``-T <rrid>`` narrows the
6+
fan-out to a single template, ``_resolve_job_rrids`` returns exactly one
7+
rrid and ``len(rrids) <= 1`` takes the single-job path (the stable
8+
``<command>-<n>`` id shape, with no RRID token embedded). The existing
9+
``test_start_jobs_explicit_template_yields_single_job`` (in
10+
``tests/test_mcp_jobs.py``) asserts only the job count and output, not
11+
the id shape, so a ``<=`` -> ``<`` mutant (which pushes this case onto
12+
the fan-out path instead) survives. This module re-drives that same
13+
scenario and additionally pins the id shape.
14+
* ``job_status``'s unknown-id refusal raises the same uniform
15+
:class:`McpCommandError` envelope (``stdout=""``, ``exit_code=1``)
16+
every other tool refusal uses, but the existing
17+
``test_job_status_unknown_id_raises`` only regex-matches the stderr
18+
message, so mutants on the envelope's ``stdout``/``exit_code``
19+
constants survive. ``job_result`` and ``job_cancel`` raise the
20+
identical envelope for the same unknown-id case, so one parametrized
21+
test pins all three.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import asyncio
27+
import logging
28+
from pathlib import Path
29+
from typing import ClassVar
30+
from unittest.mock import MagicMock
31+
32+
import pytest
33+
34+
from mtui.commands import Command
35+
from mtui.mcp.session import McpCommandError, McpSession
36+
37+
38+
def _config(tmp_path: Path) -> MagicMock:
39+
cfg = MagicMock()
40+
cfg.template_dir = tmp_path
41+
cfg.target_tempdir = tmp_path / "target"
42+
cfg.chdir_to_template_dir = False
43+
cfg.connection_timeout = 30
44+
cfg.session_user = "testuser"
45+
return cfg
46+
47+
48+
def _make_session(tmp_path: Path) -> McpSession:
49+
return McpSession(
50+
_config(tmp_path), logging.getLogger("test.mcp.session.leftovers")
51+
)
52+
53+
54+
def _load_two_reports(sess: McpSession) -> None:
55+
"""Add two MagicMock reports so a fan-out command resolves to both."""
56+
for rrid in ("SUSE:Maintenance:1:1", "SUSE:Maintenance:2:1"):
57+
report = MagicMock()
58+
report.id = rrid
59+
report.targets = {}
60+
sess.templates.add(report)
61+
sess.templates.set_active("SUSE:Maintenance:1:1")
62+
63+
64+
def _fanout_probe():
65+
"""Build a throwaway fast fan-out command; caller must unregister it."""
66+
67+
class _FanoutJobProbeLeftovers(Command):
68+
command = "fanout_job_probe_leftovers_tmp"
69+
scope: ClassVar[str] = "fanout"
70+
71+
@classmethod
72+
def _add_arguments(cls, parser) -> None:
73+
cls._add_template_arg(parser)
74+
75+
def __call__(self) -> None:
76+
self.println(str(self.metadata.id))
77+
78+
return _FanoutJobProbeLeftovers
79+
80+
81+
# --------------------------------------------------------------------------- #
82+
# start_jobs: explicit -T narrowing keeps the single-job id shape #
83+
# --------------------------------------------------------------------------- #
84+
85+
86+
def test_start_jobs_explicit_template_keeps_the_single_job_id_shape(
87+
tmp_path: Path,
88+
) -> None:
89+
"""A client-supplied ``-T`` narrows to one template: legacy id, no RRID.
90+
91+
``len(rrids) <= 1`` must route here even though ``rrids`` is
92+
non-empty; a ``<=`` -> ``<`` mutant instead sends this down the
93+
fan-out path, which embeds the (sanitised) RRID in the job id.
94+
"""
95+
sess = _make_session(tmp_path)
96+
_load_two_reports(sess)
97+
cls = _fanout_probe()
98+
99+
async def driver() -> list[str]:
100+
ids = await sess.start_jobs(cls, ["-T", "SUSE:Maintenance:2:1"])
101+
for jid in ids:
102+
await sess._jobs[jid]["task"]
103+
return ids
104+
105+
try:
106+
ids = asyncio.run(driver())
107+
finally:
108+
Command.registry.pop(cls.command, None)
109+
110+
assert len(ids) == 1
111+
assert ids[0].startswith(f"{cls.command}-")
112+
assert "SUSE" not in ids[0]
113+
assert sess.job_result(ids[0]).strip() == "SUSE:Maintenance:2:1"
114+
115+
116+
# --------------------------------------------------------------------------- #
117+
# Unknown job id: the uniform McpCommandError envelope #
118+
# --------------------------------------------------------------------------- #
119+
120+
121+
@pytest.mark.parametrize(
122+
"invoke",
123+
[
124+
lambda s: s.job_status("nope-1"),
125+
lambda s: s.job_result("nope-1"),
126+
lambda s: asyncio.run(s.job_cancel("nope-1")),
127+
],
128+
ids=["job_status", "job_result", "job_cancel"],
129+
)
130+
def test_unknown_job_id_raises_the_uniform_envelope(invoke, tmp_path: Path) -> None:
131+
"""Every unknown-id lookup raises stdout='', exit_code=1 verbatim."""
132+
sess = _make_session(tmp_path)
133+
134+
with pytest.raises(McpCommandError) as ei:
135+
invoke(sess)
136+
137+
assert ei.value.stdout == ""
138+
assert ei.value.stderr == "no such job: nope-1"
139+
assert ei.value.exit_code == 1
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Mutation-killing pins for two leftover gaps in :mod:`mtui.mcp._slim`.
2+
3+
* ``_slim``'s ``keys_are_names`` flag must propagate correctly through
4+
``items`` sub-schemas and ``anyOf`` list members so a nested ``title``
5+
still gets dropped. ``tests/test_mcp_slim.py`` drives the real
6+
pydantic-generated schemas for mtui commands, none of which happen to
7+
carry a ``title`` inside an ``items`` dict or an ``anyOf`` list member,
8+
so an ``and`` -> ``or`` mutant on the propagation expression (and the
9+
twin "list items always recurse as non-names" mutant) survived. This
10+
test hand-builds a schema shaped exactly like that to exercise both
11+
paths directly.
12+
* ``slim_registered_tools``: the existing
13+
``test_slim_registered_tools_reduces_bytes_keeps_count`` only checks
14+
the aggregate byte count, tool count, and absence of ``title``
15+
keywords — all of which pass even if every tool's ``parameters`` were
16+
replaced by ``None`` (``json.dumps(None)`` is 4 bytes, and the
17+
title-walker no-ops on non-dict/non-list nodes). This test spot-checks
18+
one specific tool still has a ``dict`` schema with its expected
19+
parameter names after slimming.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import pytest
25+
26+
pytest.importorskip("mcp")
27+
28+
from mcp.server.fastmcp import FastMCP # noqa: E402
29+
30+
from mtui.mcp._slim import _slim, slim_registered_tools # noqa: E402
31+
from mtui.mcp.tools import build_tools, register_job_tools # noqa: E402
32+
33+
34+
class _Provider:
35+
async def get_or_create(self, key): # noqa: ANN001, ANN201, D102
36+
raise AssertionError("not used in schema tests")
37+
38+
39+
# --------------------------------------------------------------------------- #
40+
# _slim: keys_are_names propagates into items/ and anyOf members #
41+
# --------------------------------------------------------------------------- #
42+
43+
44+
def test_slim_drops_title_nested_in_items_and_anyof_members() -> None:
45+
"""A hand-built schema with ``title`` inside ``items`` and ``anyOf``.
46+
47+
Also carries an ``enum`` sibling *after* ``description`` inside the
48+
``items`` sub-schema so a ``continue`` -> ``break`` mutant on the
49+
description branch (which would abandon the rest of that dict's
50+
keys) is caught too: ``enum`` must still survive.
51+
"""
52+
schema = {
53+
"properties": {
54+
"x": {
55+
"type": "array",
56+
"items": {
57+
"title": "T",
58+
"description": "d",
59+
"enum": ["a", "b"],
60+
},
61+
}
62+
},
63+
"anyOf": [
64+
{"title": "U", "type": "string"},
65+
{"type": "integer"},
66+
],
67+
}
68+
69+
result = _slim(schema, keys_are_names=False)
70+
71+
assert result == {
72+
"properties": {
73+
"x": {
74+
"type": "array",
75+
"items": {"description": "d", "enum": ["a", "b"]},
76+
}
77+
},
78+
"anyOf": [
79+
{"type": "string"},
80+
{"type": "integer"},
81+
],
82+
}
83+
84+
def _walk_no_title(node: object) -> None:
85+
if isinstance(node, dict):
86+
assert "title" not in node
87+
for value in node.values():
88+
_walk_no_title(value)
89+
elif isinstance(node, list):
90+
for item in node:
91+
_walk_no_title(item)
92+
93+
_walk_no_title(result)
94+
95+
96+
# --------------------------------------------------------------------------- #
97+
# slim_registered_tools: schemas stay real dicts, not a wholesale wipe #
98+
# --------------------------------------------------------------------------- #
99+
100+
101+
def test_slim_registered_tools_keeps_a_real_schema_on_a_known_tool() -> None:
102+
"""After slimming, ``job_status`` still has a dict schema with ``job_id``.
103+
104+
A mutant that replaces every tool's ``parameters`` with ``None``
105+
(or slims ``None`` instead of the real schema, yielding the same
106+
``None`` result) satisfies the byte-count and no-title checks in
107+
``test_slim_registered_tools_reduces_bytes_keeps_count`` but would
108+
break every MCP client's tool calls. This spot-checks one concrete,
109+
stable tool's schema shape survives slimming intact.
110+
"""
111+
mcp = FastMCP(name="mtui-test")
112+
prov = _Provider()
113+
build_tools(mcp, prov)
114+
register_job_tools(mcp, prov)
115+
116+
tool = mcp._tool_manager.get_tool("job_status") # noqa: SLF001
117+
assert tool is not None
118+
119+
n = slim_registered_tools(mcp)
120+
assert n > 0
121+
122+
params = tool.parameters
123+
assert isinstance(params, dict)
124+
assert "properties" in params
125+
assert "job_id" in params["properties"]
126+
assert params["properties"]["job_id"].get("type") == "string"

0 commit comments

Comments
 (0)