/
/
/
1"""Tests for ToolAnnotations sweep across all sub-server tools (C5)."""
2# mypy: disable-error-code="arg-type, no-untyped-def, type-arg, assignment, operator, misc"
3
4from __future__ import annotations
5
6from typing import Any
7
8import pytest
9from fastmcp import Client, FastMCP
10
11from music_assistant.providers.fastmcp_server.tools import (
12 build_library_server,
13 build_media_server,
14 build_metadata_server,
15 build_playback_server,
16 build_players_server,
17 build_playlists_server,
18 build_queue_server,
19 build_volume_server,
20)
21
22_BUILDERS = [
23 ("library", build_library_server),
24 ("queue", build_queue_server),
25 ("playback", build_playback_server),
26 ("players", build_players_server),
27 ("playlists", build_playlists_server),
28 ("volume", build_volume_server),
29 ("media", build_media_server),
30 ("metadata", build_metadata_server),
31]
32
33
34# Spec-mandated destructive tools per the C5 mapping table.
35_DESTRUCTIVE_NAMES = {
36 "queue_add_to_queue",
37 "queue_clear_queue",
38 "queue_remove_item",
39 "playlists_remove_tracks",
40 "media_remove_from_favorites",
41 "media_remove_from_library",
42}
43
44# Strictly read-only categories (every tool in these sub-servers).
45_READONLY_NAMESPACES = {"library", "metadata"}
46# Plus a couple of inspector tools elsewhere.
47_READONLY_TOOL_NAMES = {
48 "queue_get_active_queue",
49 "players_list_players",
50 "players_get_player",
51}
52
53
54@pytest.fixture
55def mounted_server(mock_mass: Any) -> FastMCP:
56 """Build a root FastMCP with all 8 sub-servers mounted, like MCPServerRuntime."""
57 mcp: FastMCP = FastMCP(name="test")
58 for ns, builder in _BUILDERS:
59 mcp.mount(builder(mock_mass), namespace=ns)
60 return mcp
61
62
63async def test_every_tool_has_title_and_annotations(mounted_server: FastMCP) -> None:
64 """Every exposed tool carries a non-empty title and a ToolAnnotations object."""
65 async with Client(mounted_server) as client:
66 tools = await client.list_tools()
67 assert tools, "expected at least one tool"
68 for tool in tools:
69 ann = tool.annotations
70 assert ann is not None, f"{tool.name}: missing annotations"
71 assert ann.title, f"{tool.name}: missing title"
72 # openWorldHint must be explicitly False â none of our tools touch the open web.
73 assert ann.openWorldHint is False, f"{tool.name}: unexpected openWorldHint=True"
74
75
76async def test_destructive_tools_are_marked(mounted_server: FastMCP) -> None:
77 """Destructive operations are flagged so clients can prompt for confirmation."""
78 async with Client(mounted_server) as client:
79 tools = {t.name: t for t in await client.list_tools()}
80 for name in _DESTRUCTIVE_NAMES:
81 assert name in tools, f"missing tool {name}"
82 assert tools[name].annotations.destructiveHint is True, name
83
84
85async def test_readonly_tools_are_marked(mounted_server: FastMCP) -> None:
86 """Read-only sub-servers (library, metadata) and a few inspectors carry readOnlyHint=True."""
87 async with Client(mounted_server) as client:
88 tools = await client.list_tools()
89 for tool in tools:
90 ns = tool.name.split("_", 1)[0]
91 if ns in _READONLY_NAMESPACES or tool.name in _READONLY_TOOL_NAMES:
92 assert tool.annotations.readOnlyHint is True, tool.name
93 assert tool.annotations.destructiveHint is False, tool.name
94