/
/
/
1"""
2Tests for the lean-output-schema toggle on the config/debug namespaces.
3
4Spec: ``specs/done/0009-tool-schema-context-budget-policy.md``.
5
6When the lean toggle is on, the gated admin namespaces (config, debug) register
7their tools without an ``outputSchema`` so the per-turn tool-schema footprint
8shrinks for MCP hosts that lack tool-search deferred loading. The typed return
9value must still reach the client as JSON text, and annotations must be intact.
10"""
11# mypy: disable-error-code="arg-type, no-untyped-def, type-arg, assignment, misc, union-attr"
12
13from __future__ import annotations
14
15import json
16from typing import Any
17
18from fastmcp import Client, FastMCP
19
20from music_assistant.providers.fastmcp_server.tools import build_config_server, build_debug_server
21
22
23def _mount(mock_mass: Any, *, lean_schema: bool) -> FastMCP:
24 """Mount config + debug exactly as MCPServerRuntime does, with the lean flag."""
25 mcp: FastMCP = FastMCP(name="test")
26 mcp.mount(build_config_server(mock_mass, lean_schema=lean_schema), namespace="config")
27 mcp.mount(build_debug_server(mock_mass, lean_schema=lean_schema), namespace="debug")
28 return mcp
29
30
31async def _tools(server: FastMCP) -> dict[str, Any]:
32 async with Client(server) as client:
33 return {t.name: t for t in await client.list_tools()}
34
35
36async def test_default_keeps_output_schema(mock_mass: Any) -> None:
37 """With the lean flag off (default), config/debug tools keep their outputSchema."""
38 tools = await _tools(_mount(mock_mass, lean_schema=False))
39 with_schema = [n for n, t in tools.items() if t.outputSchema]
40 assert with_schema, "expected config/debug tools to carry an outputSchema by default"
41
42
43async def test_lean_schema_omits_output_schema(mock_mass: Any) -> None:
44 """With the lean flag on, every config/debug tool omits its outputSchema."""
45 tools = await _tools(_mount(mock_mass, lean_schema=True))
46 assert tools, "expected config/debug tools to be mounted"
47 offenders = [n for n, t in tools.items() if t.outputSchema]
48 assert not offenders, f"these tools still carry an outputSchema in lean mode: {offenders}"
49
50
51async def test_lean_schema_preserves_annotations(mock_mass: Any) -> None:
52 """Dropping outputSchema must not strip the required title/hint annotations."""
53 tools = await _tools(_mount(mock_mass, lean_schema=True))
54 for name, tool in tools.items():
55 assert tool.annotations is not None, f"{name}: lost annotations in lean mode"
56 assert tool.annotations.title, f"{name}: lost title in lean mode"
57
58
59async def test_lean_schema_reduces_total_schema_bytes(mock_mass: Any) -> None:
60 """
61 Lean mode is a real context saving: serialized tool schemas shrink â¥25%.
62
63 This doubles as the budget guard â it fails loudly if a future refactor
64 inlines the output shape somewhere the toggle does not reach.
65 """
66
67 async def total_bytes(*, lean_schema: bool) -> int:
68 tools = await _tools(_mount(mock_mass, lean_schema=lean_schema))
69 return sum(
70 len(json.dumps(t.model_dump(exclude_none=True, by_alias=True), default=list))
71 for t in tools.values()
72 )
73
74 default_bytes = await total_bytes(lean_schema=False)
75 lean_bytes = await total_bytes(lean_schema=True)
76 assert lean_bytes < default_bytes * 0.75, (
77 f"expected â¥25% schema shrink, got {default_bytes} â {lean_bytes} "
78 f"({100 * lean_bytes // default_bytes}% of original)"
79 )
80
81
82async def test_lean_schema_tool_still_returns_data(mock_mass: Any) -> None:
83 """A lean tool still delivers its dataclass payload as JSON text content."""
84 server = _mount(mock_mass, lean_schema=True)
85 async with Client(server) as client:
86 result = await client.call_tool("debug_list_package_versions", {})
87 text = "".join(block.text for block in result.content if hasattr(block, "text"))
88 assert "fastmcp" in text, f"expected package data in text content, got: {text!r}"
89