From a0ca35e87180fde6d0b25182e5dd2a6b7aea497a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:11:41 -0500 Subject: [PATCH 01/23] fastmcp(feat[safety]): Let a project name its own tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The tier vocabulary was three literals, and an unrecognized tag fell through to `readonly`. A project renaming its tags — libtmux-mcp is about to — would have every tool badged read-only, including the ones that run commands. A badge that names a tier nobody assigned is worse than no badge. what: - Add `fastmcp_safety_tiers`: an ordered vocabulary, highest precedence first, each entry a tag name or a mapping with a tooltip and icon - Default to `destructive` / `mutating` / `readonly`, so a docs build that declares nothing renders exactly as before - Resolve an unmatched tool to no tier, and omit its safety badge rather than reporting the lowest one - Install the vocabulary at `builder-inited`; badges are built from call sites with no `app` in scope, so threading it through each one would reach further than the change needs to --- .../src/sphinx_autodoc_fastmcp/__init__.py | 20 +++ .../src/sphinx_autodoc_fastmcp/_badges.py | 95 +++++++------- .../src/sphinx_autodoc_fastmcp/_collector.py | 29 ++--- .../src/sphinx_autodoc_fastmcp/_models.py | 118 ++++++++++++++++++ tests/ext/fastmcp/test_fastmcp.py | 69 ++++++++++ 5 files changed, 272 insertions(+), 59 deletions(-) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py index 62b999bf..2449ea6b 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -15,6 +15,7 @@ from sphinx.application import Sphinx +from sphinx_autodoc_fastmcp._badges import use_safety_tiers from sphinx_autodoc_fastmcp._collector import ( collect_prompts_and_resources, collect_tools, @@ -28,6 +29,7 @@ FastMCPToolInputDirective, FastMCPToolSummaryDirective, ) +from sphinx_autodoc_fastmcp._models import coerce_safety_tiers from sphinx_autodoc_fastmcp._roles import ( _prompt_role, _promptref_role, @@ -144,6 +146,20 @@ def setup(app: Sphinx) -> dict[str, t.Any]: "section headings." ), ) + app.add_config_value( + "fastmcp_safety_tiers", + (), + "env", + description=( + "Safety vocabulary this project tags its tools with, in " + "precedence order, highest first. Each entry is a tag name or " + 'a mapping with ``"tag"`` and optional ``"tooltip"`` / ' + '``"icon"``. Empty keeps ``destructive`` / ``mutating`` / ' + "``readonly``. A tool carrying none of these tags renders " + "without a safety badge rather than being reported as the " + "lowest tier." + ), + ) app.add_config_value( "fastmcp_collector_mode", "register", @@ -173,6 +189,10 @@ def _add_static_path(app: Sphinx) -> None: if _static_dir not in app.config.html_static_path: app.config.html_static_path.append(_static_dir) + def _install_safety_tiers(app: Sphinx) -> None: + use_safety_tiers(coerce_safety_tiers(app.config.fastmcp_safety_tiers)) + + app.connect("builder-inited", _install_safety_tiers) app.connect("builder-inited", _add_static_path) app.add_css_file("css/sphinx_autodoc_fastmcp.css") diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py index 995af20d..3255de5e 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py @@ -7,6 +7,7 @@ from docutils import nodes from sphinx_autodoc_fastmcp._css import _CSS +from sphinx_autodoc_fastmcp._models import DEFAULT_SAFETY_TIERS, SafetyTier from sphinx_ux_badges import ( SAB, BadgeNode, @@ -16,19 +17,45 @@ build_toolbar as _sab_build_toolbar, ) -_SAFETY_LABELS = ("readonly", "mutating", "destructive") +#: Vocabulary in force for the current build. Badges are built from +#: several call sites that have no ``app`` in scope, so the extension +#: installs it once at ``builder-inited`` rather than threading it +#: through every one. +_ACTIVE_TIERS: tuple[SafetyTier, ...] = DEFAULT_SAFETY_TIERS -_SAFETY_TOOLTIPS: dict[str, str] = { - "readonly": "Read-only \u2014 does not modify external state", - "mutating": "Mutating \u2014 creates or modifies objects", - "destructive": "Destructive \u2014 may remove data; not reversible", -} -_SAFETY_ICONS: dict[str, str] = { - "readonly": "\U0001f50d", - "mutating": "\u270f\ufe0f", - "destructive": "\U0001f4a3", -} +def use_safety_tiers(tiers: t.Sequence[SafetyTier] | None) -> None: + """Install the vocabulary badges render from. + + Parameters + ---------- + tiers : sequence of SafetyTier or None + Vocabulary for this build. ``None`` restores the default. + """ + global _ACTIVE_TIERS + _ACTIVE_TIERS = DEFAULT_SAFETY_TIERS if tiers is None else tuple(tiers) + + +def _tier(safety: str) -> SafetyTier | None: + """Return the active tier named ``safety``, or ``None``.""" + return next((tier for tier in _ACTIVE_TIERS if tier.tag == safety), None) + + +def _safety_spec(safety: str) -> BadgeSpec: + """Return the badge spec for a tier, honouring the active vocabulary.""" + tier = _tier(safety) + return BadgeSpec( + safety, + tooltip=(tier.tooltip if tier and tier.tooltip else f"Safety: {safety}"), + icon=(tier.icon if tier else ""), + classes=( + SAB.DENSE, + SAB.NO_UNDERLINE, + _CSS.BADGE_SAFETY, + _CSS.safety_class(safety), + ), + ) + _TYPE_TOOLTIP = "MCP tool" @@ -57,22 +84,15 @@ def build_safety_badge( >>> b.astext() 'readonly' """ - label = safety if safety in _SAFETY_LABELS else safety - text = "" if icon_only else label + spec = _safety_spec(safety) style: t.Literal["full", "icon-only", "inline-icon"] = ( "icon-only" if icon_only else "full" ) - classes = [ - SAB.DENSE, - SAB.NO_UNDERLINE, - _CSS.BADGE_SAFETY, - _CSS.safety_class(safety), - ] return build_badge( - text, - tooltip=_SAFETY_TOOLTIPS.get(safety, f"Safety: {safety}"), - icon=_SAFETY_ICONS.get(safety, ""), - classes=classes, + "" if icon_only else safety, + tooltip=spec.tooltip, + icon=spec.icon, + classes=list(spec.classes), style=style, ) @@ -111,26 +131,17 @@ def build_tool_badge_group(safety: str) -> nodes.inline: >>> "gp-sphinx-badge-group" in g["classes"] True """ - return build_badge_group_from_specs( - [ - BadgeSpec( - safety if safety in _SAFETY_LABELS else safety, - tooltip=_SAFETY_TOOLTIPS.get(safety, f"Safety: {safety}"), - icon=_SAFETY_ICONS.get(safety, ""), - classes=( - SAB.DENSE, - SAB.NO_UNDERLINE, - _CSS.BADGE_SAFETY, - _CSS.safety_class(safety), - ), - ), - BadgeSpec( - "tool", - tooltip=_TYPE_TOOLTIP, - classes=(SAB.DENSE, SAB.NO_UNDERLINE, SAB.BADGE_TYPE, _CSS.TYPE_TOOL), - ), - ], + specs: list[BadgeSpec] = [] + if safety: + specs.append(_safety_spec(safety)) + specs.append( + BadgeSpec( + "tool", + tooltip=_TYPE_TOOLTIP, + classes=(SAB.DENSE, SAB.NO_UNDERLINE, SAB.BADGE_TYPE, _CSS.TYPE_TOOL), + ) ) + return build_badge_group_from_specs(specs) def build_toolbar(safety: str) -> nodes.inline: diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py index dfd76845..cf02c53c 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py @@ -11,21 +11,21 @@ from sphinx.application import Sphinx from sphinx_autodoc_fastmcp._models import ( + DEFAULT_SAFETY_TIERS, PromptArgInfo, PromptInfo, ResourceInfo, ResourceTemplateInfo, + SafetyTier, ToolInfo, + coerce_safety_tiers, + resolve_safety, ) from sphinx_autodoc_fastmcp._parsing import extract_params, first_paragraph from sphinx_autodoc_typehints_gp import normalize_annotation_text logger = logging.getLogger(__name__) -TAG_READONLY = "readonly" -TAG_MUTATING = "mutating" -TAG_DESTRUCTIVE = "destructive" - class ToolCollector: """Mock FastMCP server that captures tool registrations.""" @@ -34,10 +34,12 @@ def __init__( self, *, area_map: dict[str, str], + safety_tiers: tuple[SafetyTier, ...] = DEFAULT_SAFETY_TIERS, ) -> None: self.tools: list[ToolInfo] = [] self._current_module: str = "" self._area_map = area_map + self.safety_tiers = safety_tiers def tool( self, @@ -50,12 +52,7 @@ def tool( tags = tags or set() def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: - if TAG_DESTRUCTIVE in tags: - safety = "destructive" - elif TAG_MUTATING in tags: - safety = "mutating" - else: - safety = "readonly" + safety = resolve_safety(tags, self.safety_tiers) module_name = self._current_module area = self._area_map.get( @@ -89,6 +86,7 @@ def _tool_from_callable( *, module_name: str, area_map: dict[str, str], + safety_tiers: tuple[SafetyTier, ...] = DEFAULT_SAFETY_TIERS, ) -> ToolInfo | None: """Build ``ToolInfo`` from a decorated function (``__fastmcp__``).""" meta = getattr(func, "__fastmcp__", None) @@ -97,12 +95,7 @@ def _tool_from_callable( tags = getattr(meta, "tags", None) or set() if not isinstance(tags, set): tags = set(tags) if tags else set() - if TAG_DESTRUCTIVE in tags: - safety = "destructive" - elif TAG_MUTATING in tags: - safety = "mutating" - else: - safety = "readonly" + safety = resolve_safety(tags, safety_tiers) area = area_map.get(module_name, module_name.replace("_tools", "")) name = getattr(meta, "name", None) or func.__name__ title = getattr(meta, "title", None) or name.replace("_", " ").title() @@ -138,6 +131,7 @@ def collect_tools(app: Sphinx) -> None: """Populate ``app.env.fastmcp_tools`` from configured modules.""" modules: list[str] = list(app.config.fastmcp_tool_modules) area_map: dict[str, str] = dict(app.config.fastmcp_area_map) + safety_tiers = coerce_safety_tiers(app.config.fastmcp_safety_tiers) mode = str(app.config.fastmcp_collector_mode) if mode not in ("register", "introspect"): logger.warning( @@ -156,7 +150,7 @@ def collect_tools(app: Sphinx) -> None: collector_tools: list[ToolInfo] = [] if mode == "register": - collector = ToolCollector(area_map=area_map) + collector = ToolCollector(area_map=area_map, safety_tiers=safety_tiers) for dotted in modules: mod_suffix = dotted.split(".")[-1] collector._current_module = mod_suffix @@ -190,6 +184,7 @@ def collect_tools(app: Sphinx) -> None: obj, module_name=mod_suffix, area_map=area_map, + safety_tiers=safety_tiers, ) if info is not None: collector_tools.append(info) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py index 56693ff4..7b9fbd07 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py @@ -6,6 +6,124 @@ from dataclasses import dataclass, field +@dataclass(frozen=True) +class SafetyTier: + """One entry in the vocabulary a project tags its tools with. + + Attributes + ---------- + tag : str + Tag to look for in a tool's ``tags`` set. + tooltip : str + Hover text for the badge. Falls back to ``"Safety: "``. + icon : str + Emoji rendered before the label. Optional. + """ + + tag: str + tooltip: str = "" + icon: str = "" + + +#: The vocabulary assumed when a project declares none, in precedence +#: order. Matches the tags FastMCP projects have used since this +#: extension shipped, so an existing docs build renders unchanged. +DEFAULT_SAFETY_TIERS: tuple[SafetyTier, ...] = ( + SafetyTier( + "destructive", + "Destructive \u2014 may remove data; not reversible", + "\U0001f4a3", + ), + SafetyTier( + "mutating", "Mutating \u2014 creates or modifies objects", "\u270f\ufe0f" + ), + SafetyTier( + "readonly", + "Read-only \u2014 does not modify external state", + "\U0001f50d", + ), +) + + +def coerce_safety_tiers(value: t.Any) -> tuple[SafetyTier, ...]: + """Return a tier vocabulary from a ``fastmcp_safety_tiers`` value. + + Accepts what a ``conf.py`` can express: a sequence of mappings, of + :class:`SafetyTier`, or of bare tag strings. An empty value means the + project declared none, so :data:`DEFAULT_SAFETY_TIERS` applies. + + Parameters + ---------- + value : object + Raw configuration value. + + Returns + ------- + tuple of SafetyTier + Vocabulary in precedence order, highest first. + + Examples + -------- + >>> coerce_safety_tiers(())[0].tag + 'destructive' + >>> [tier.tag for tier in coerce_safety_tiers(("execute", "inspect"))] + ['execute', 'inspect'] + """ + if not value: + return DEFAULT_SAFETY_TIERS + tiers: list[SafetyTier] = [] + for entry in value: + if isinstance(entry, SafetyTier): + tiers.append(entry) + elif isinstance(entry, str): + tiers.append(SafetyTier(entry)) + else: + tiers.append( + SafetyTier( + entry["tag"], + entry.get("tooltip", ""), + entry.get("icon", ""), + ) + ) + return tuple(tiers) + + +def resolve_safety( + tags: t.Iterable[str], + tiers: t.Sequence[SafetyTier] = DEFAULT_SAFETY_TIERS, +) -> str: + """Return the tier a tool's tags place it in, highest precedence first. + + Returns the empty string when no tag matches. Naming a default tier + here would report a tool as belonging to a tier nobody assigned it + to, which is the one answer a badge must never give. + + Parameters + ---------- + tags : iterable of str + The tool's tags. + tiers : sequence of SafetyTier + Vocabulary in precedence order. + + Returns + ------- + str + Matching tag, or ``""`` when the tool carries none of them. + + Examples + -------- + >>> resolve_safety({"mutating"}) + 'mutating' + >>> resolve_safety({"execute"}) + '' + """ + present = set(tags) + for tier in tiers: + if tier.tag in present: + return tier.tag + return "" + + @dataclass class ParamInfo: """Extracted parameter information for a tool. diff --git a/tests/ext/fastmcp/test_fastmcp.py b/tests/ext/fastmcp/test_fastmcp.py index a4bc12cd..b34f4094 100644 --- a/tests/ext/fastmcp/test_fastmcp.py +++ b/tests/ext/fastmcp/test_fastmcp.py @@ -182,3 +182,72 @@ def test_resolve_server_returns_none_when_factory_yields_non_fastmcp( resolved = _resolve_server_instance("fake_fastmcp_factory:mcp") assert resolved is None + + +def test_default_safety_vocabulary_matches_the_shipped_tiers() -> None: + """The default keeps `readonly` / `mutating` / `destructive` behaviour.""" + from sphinx_autodoc_fastmcp._models import DEFAULT_SAFETY_TIERS, resolve_safety + + assert resolve_safety({"destructive"}, DEFAULT_SAFETY_TIERS) == "destructive" + assert resolve_safety({"mutating"}, DEFAULT_SAFETY_TIERS) == "mutating" + assert resolve_safety({"readonly"}, DEFAULT_SAFETY_TIERS) == "readonly" + # Precedence: the highest tier present wins, whatever the set order. + assert ( + resolve_safety({"readonly", "destructive"}, DEFAULT_SAFETY_TIERS) + == "destructive" + ) + + +def test_an_unrecognized_tag_is_not_reported_as_readonly() -> None: + """A tool tagged outside the vocabulary must not claim to be read-only.""" + from sphinx_autodoc_fastmcp._models import DEFAULT_SAFETY_TIERS, resolve_safety + + assert resolve_safety({"execute"}, DEFAULT_SAFETY_TIERS) == "" + assert resolve_safety(set(), DEFAULT_SAFETY_TIERS) == "" + + +def test_a_project_can_supply_its_own_safety_vocabulary() -> None: + """A renamed tag set resolves once the project declares it.""" + from sphinx_autodoc_fastmcp._models import coerce_safety_tiers, resolve_safety + + tiers = coerce_safety_tiers( + ( + { + "tag": "teardown", + "tooltip": "Removes tmux objects", + "icon": "\U0001f4a3", + }, + {"tag": "execute"}, + {"tag": "manage"}, + {"tag": "inspect"}, + ) + ) + + assert resolve_safety({"execute"}, tiers) == "execute" + assert resolve_safety({"inspect", "teardown"}, tiers) == "teardown" + assert resolve_safety({"readonly"}, tiers) == "" + assert tiers[0].tooltip == "Removes tmux objects" + + +def test_a_tool_outside_the_vocabulary_gets_no_safety_badge() -> None: + """No badge is honest; a badge naming a tier nobody assigned is not.""" + group = build_tool_badge_group("") + + assert group.astext() == "tool" + + +def test_configured_tiers_supply_the_badge_tooltip_and_icon() -> None: + """A project's own vocabulary reaches the rendered badge.""" + from sphinx_autodoc_fastmcp._badges import use_safety_tiers + from sphinx_autodoc_fastmcp._models import coerce_safety_tiers + + use_safety_tiers( + coerce_safety_tiers(({"tag": "execute", "tooltip": "Runs a command"},)) + ) + try: + badge = build_safety_badge("execute") + assert badge["badge_tooltip"] == "Runs a command" + finally: + use_safety_tiers(None) + + assert build_safety_badge("execute")["badge_tooltip"] == "Safety: execute" From d8685d8b4a80d45abae50749edd6507ad4839fa1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:12:50 -0500 Subject: [PATCH 02/23] docs(fastmcp): Show how to name your own tiers why: The new config value needs a worked example, and the rule that a tool outside the vocabulary loses its badge needs saying where a consumer will look for it. what: - Add a how-to section with a four-tier example, the precedence rule, and the CSS class a new tier name produces --- .../packages/sphinx-autodoc-fastmcp/how-to.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/packages/sphinx-autodoc-fastmcp/how-to.md b/docs/packages/sphinx-autodoc-fastmcp/how-to.md index 23ea9579..ccdf1415 100644 --- a/docs/packages/sphinx-autodoc-fastmcp/how-to.md +++ b/docs/packages/sphinx-autodoc-fastmcp/how-to.md @@ -25,6 +25,34 @@ fastmcp_collector_mode = "register" fastmcp_server_module = "my_project.server:mcp" ``` +## Name your own safety tiers + +Tools are badged from their tags. The default vocabulary is `destructive`, +`mutating`, `readonly`, in that precedence order — declare +`fastmcp_safety_tiers` when your project tags its tools differently: + +```python +fastmcp_safety_tiers = ( + {"tag": "teardown", "tooltip": "Deletes tmux objects", "icon": "💣"}, + {"tag": "execute", "tooltip": "Runs a command in a pane"}, + "manage", + "inspect", +) +``` + +Order is precedence: a tool carrying several of these tags is badged with the +first one listed. An entry may be a bare tag name or a mapping with a `tooltip` +and an `icon`. + +A tool carrying none of the tags renders **without** a safety badge. The +alternative — falling back to the last tier — would badge a tool with a name +nobody gave it, and read-only is the worst possible guess for a tool the +vocabulary does not cover. + +Each tier gets the CSS class `gp-sphinx-fastmcp__safety-`. The shipped +stylesheet colours the three default tiers; a project introducing new names +styles them in its own CSS. + `sphinx_autodoc_fastmcp` automatically registers `sphinx_ux_badges`, `sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via {py:meth}`~sphinx.application.Sphinx.setup_extension`. You do not need to add From 544eac39ba4b6ca2e04cebe0cec8006576da8b1f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:18:31 -0500 Subject: [PATCH 03/23] fastmcp(feat[toolsets]): Rename safety to toolset why: "Safety" was the misrepresentation, not just the three tag names. A tier reads as a permission level; these are groups of tools named for what they do, and this extension has no standing to call one safer than another in a project whose tags it does not choose. The rename also surfaced a fourth hardcoded site the first pass missed: `{fastmcp-summary}` grouped its tables under `readonly` / `mutating` / `destructive` with fixed headings, so a project with other tags got empty tables. what: - Rename the concept throughout: `fastmcp_toolsets`, `Toolset`, `build_toolset_badge`, `ToolInfo.toolset`, and the rendered class `gp-sphinx-fastmcp__toolset-` - Drive the summary directive from the declared vocabulary, titling each section from the tag and describing it from the tooltip - Ship no default vocabulary: a default badges a project's tools with words it never used, and an undeclared setting should be visible rather than silently plausible - Retag this project's own demo tools, which were still teaching the tiers on the examples page --- docs/_ext/fastmcp_demo_tools.py | 6 +- docs/conf.py | 17 ++++ .../packages/sphinx-autodoc-fastmcp/how-to.md | 37 ++++---- .../src/sphinx_autodoc_fastmcp/__init__.py | 18 ++-- .../src/sphinx_autodoc_fastmcp/_badges.py | 69 +++++++------- .../src/sphinx_autodoc_fastmcp/_collector.py | 28 +++--- .../src/sphinx_autodoc_fastmcp/_css.py | 26 +++--- .../src/sphinx_autodoc_fastmcp/_directives.py | 25 +++--- .../src/sphinx_autodoc_fastmcp/_models.py | 90 +++++++++---------- .../src/sphinx_autodoc_fastmcp/_prototype.py | 4 +- .../src/sphinx_autodoc_fastmcp/_roles.py | 2 +- .../_static/css/sphinx_autodoc_fastmcp.css | 56 ++++++------ .../src/sphinx_autodoc_fastmcp/_transforms.py | 28 +++--- tests/ext/fastmcp/test_fastmcp.py | 80 +++++++++-------- tests/ext/fastmcp/test_fastmcp_integration.py | 2 + tests/ext/fastmcp/test_prototype.py | 2 +- .../layout/__snapshots__/test_snapshots.ambr | 4 +- tests/ext/layout/test_snapshots.py | 2 +- tests/test_docs_package_pages.py | 5 +- 19 files changed, 261 insertions(+), 240 deletions(-) diff --git a/docs/_ext/fastmcp_demo_tools.py b/docs/_ext/fastmcp_demo_tools.py index 6832c845..c2833a23 100644 --- a/docs/_ext/fastmcp_demo_tools.py +++ b/docs/_ext/fastmcp_demo_tools.py @@ -40,7 +40,7 @@ def list_sessions(server: str, limit: int = 20) -> list[str]: t.cast(t.Any, list_sessions).__fastmcp__ = types.SimpleNamespace( - name="list_sessions", title="List Sessions", tags={"readonly"}, annotations=None + name="list_sessions", title="List Sessions", tags={"inspect"}, annotations=None ) @@ -78,7 +78,7 @@ def create_session( t.cast(t.Any, create_session).__fastmcp__ = types.SimpleNamespace( - name="create_session", title="Create Session", tags={"mutating"}, annotations=None + name="create_session", title="Create Session", tags={"execute"}, annotations=None ) @@ -108,6 +108,6 @@ def delete_session(name: str, force: bool = False) -> bool: t.cast(t.Any, delete_session).__fastmcp__ = types.SimpleNamespace( name="delete_session", title="Delete Session", - tags={"destructive"}, + tags={"teardown"}, annotations=None, ) diff --git a/docs/conf.py b/docs/conf.py index 2ef0c7d8..4b3e4317 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -110,6 +110,23 @@ fastmcp_area_map={ "fastmcp_demo_tools": "packages/sphinx-autodoc-fastmcp/examples", }, + fastmcp_toolsets=( + { + "tag": "teardown", + "tooltip": "Removes objects; not reversible.", + "icon": "\N{BOMB}", + }, + { + "tag": "execute", + "tooltip": "Starts or drives a process.", + "icon": "\N{PENCIL}\N{VARIATION SELECTOR-16}", + }, + { + "tag": "inspect", + "tooltip": "Reads state without changing it.", + "icon": "\N{LEFT-POINTING MAGNIFYING GLASS}", + }, + ), fastmcp_collector_mode="introspect", api_layout_enabled=True, api_collapsed_threshold=10, diff --git a/docs/packages/sphinx-autodoc-fastmcp/how-to.md b/docs/packages/sphinx-autodoc-fastmcp/how-to.md index ccdf1415..115d2bae 100644 --- a/docs/packages/sphinx-autodoc-fastmcp/how-to.md +++ b/docs/packages/sphinx-autodoc-fastmcp/how-to.md @@ -3,7 +3,7 @@ # How to Use this extension when a FastMCP server should document its tools, -resources, prompts, generated schemas, safety metadata, and cross-reference +resources, prompts, generated schemas, toolset metadata, and cross-reference badges from live registration data. ## Downstream `conf.py` @@ -25,16 +25,21 @@ fastmcp_collector_mode = "register" fastmcp_server_module = "my_project.server:mcp" ``` -## Name your own safety tiers +## Declare your toolsets -Tools are badged from their tags. The default vocabulary is `destructive`, -`mutating`, `readonly`, in that precedence order — declare -`fastmcp_safety_tiers` when your project tags its tools differently: +Tools are badged from their tags, and this extension ships **no** default +vocabulary — it renders documentation for projects whose tags it does not +choose, so a default would badge one project's tools with another's words. +Declare `fastmcp_toolsets`: ```python -fastmcp_safety_tiers = ( - {"tag": "teardown", "tooltip": "Deletes tmux objects", "icon": "💣"}, - {"tag": "execute", "tooltip": "Runs a command in a pane"}, +fastmcp_toolsets = ( + { + "tag": "teardown", + "tooltip": "Deletes objects; not reversible.", + "icon": "\N{BOMB}", + }, + {"tag": "execute", "tooltip": "Starts or drives a process."}, "manage", "inspect", ) @@ -42,16 +47,16 @@ fastmcp_safety_tiers = ( Order is precedence: a tool carrying several of these tags is badged with the first one listed. An entry may be a bare tag name or a mapping with a `tooltip` -and an `icon`. +and an `icon`. The `{fastmcp-summary}` directive groups its tables in the same +order, titling each section from the tag. -A tool carrying none of the tags renders **without** a safety badge. The -alternative — falling back to the last tier — would badge a tool with a name -nobody gave it, and read-only is the worst possible guess for a tool the -vocabulary does not cover. +A tool carrying none of the tags renders **without** a toolset badge. Falling +back to a tag nobody assigned is the one answer a badge must never give, and +with no declared vocabulary that is every tool — which is the signal that the +setting is missing. -Each tier gets the CSS class `gp-sphinx-fastmcp__safety-`. The shipped -stylesheet colours the three default tiers; a project introducing new names -styles them in its own CSS. +Each toolset gets the CSS class `gp-sphinx-fastmcp__toolset-`. Style the +tags your project uses in your own CSS. `sphinx_autodoc_fastmcp` automatically registers `sphinx_ux_badges`, `sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py index 2449ea6b..ac5e8a6c 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -15,7 +15,7 @@ from sphinx.application import Sphinx -from sphinx_autodoc_fastmcp._badges import use_safety_tiers +from sphinx_autodoc_fastmcp._badges import use_toolsets from sphinx_autodoc_fastmcp._collector import ( collect_prompts_and_resources, collect_tools, @@ -29,7 +29,7 @@ FastMCPToolInputDirective, FastMCPToolSummaryDirective, ) -from sphinx_autodoc_fastmcp._models import coerce_safety_tiers +from sphinx_autodoc_fastmcp._models import coerce_toolsets from sphinx_autodoc_fastmcp._roles import ( _prompt_role, _promptref_role, @@ -131,7 +131,7 @@ def setup(app: Sphinx) -> dict[str, t.Any]: "env", description=( 'Mapping of docstring section heading (e.g. ``"Inspect"``) ' - "to the safety badge it should render with (e.g. " + "to the toolset badge it should render with (e.g. " '``"readonly"``, ``"mutating"``, ``"destructive"``). ' "Drives the inline section pills next to grouped tool lists." ), @@ -147,7 +147,7 @@ def setup(app: Sphinx) -> dict[str, t.Any]: ), ) app.add_config_value( - "fastmcp_safety_tiers", + "fastmcp_toolsets", (), "env", description=( @@ -156,8 +156,8 @@ def setup(app: Sphinx) -> dict[str, t.Any]: 'a mapping with ``"tag"`` and optional ``"tooltip"`` / ' '``"icon"``. Empty keeps ``destructive`` / ``mutating`` / ' "``readonly``. A tool carrying none of these tags renders " - "without a safety badge rather than being reported as the " - "lowest tier." + "without a toolset badge rather than being reported as the " + "lowest entry." ), ) app.add_config_value( @@ -189,10 +189,10 @@ def _add_static_path(app: Sphinx) -> None: if _static_dir not in app.config.html_static_path: app.config.html_static_path.append(_static_dir) - def _install_safety_tiers(app: Sphinx) -> None: - use_safety_tiers(coerce_safety_tiers(app.config.fastmcp_safety_tiers)) + def _install_toolsets(app: Sphinx) -> None: + use_toolsets(coerce_toolsets(app.config.fastmcp_toolsets)) - app.connect("builder-inited", _install_safety_tiers) + app.connect("builder-inited", _install_toolsets) app.connect("builder-inited", _add_static_path) app.add_css_file("css/sphinx_autodoc_fastmcp.css") diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py index 3255de5e..1ef8f18b 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py @@ -7,7 +7,7 @@ from docutils import nodes from sphinx_autodoc_fastmcp._css import _CSS -from sphinx_autodoc_fastmcp._models import DEFAULT_SAFETY_TIERS, SafetyTier +from sphinx_autodoc_fastmcp._models import DEFAULT_TOOLSETS, Toolset from sphinx_ux_badges import ( SAB, BadgeNode, @@ -21,38 +21,43 @@ #: several call sites that have no ``app`` in scope, so the extension #: installs it once at ``builder-inited`` rather than threading it #: through every one. -_ACTIVE_TIERS: tuple[SafetyTier, ...] = DEFAULT_SAFETY_TIERS +_ACTIVE_TOOLSETS: tuple[Toolset, ...] = DEFAULT_TOOLSETS -def use_safety_tiers(tiers: t.Sequence[SafetyTier] | None) -> None: +def use_toolsets(tiers: t.Sequence[Toolset] | None) -> None: """Install the vocabulary badges render from. Parameters ---------- - tiers : sequence of SafetyTier or None + tiers : sequence of Toolset or None Vocabulary for this build. ``None`` restores the default. """ - global _ACTIVE_TIERS - _ACTIVE_TIERS = DEFAULT_SAFETY_TIERS if tiers is None else tuple(tiers) + global _ACTIVE_TOOLSETS + _ACTIVE_TOOLSETS = DEFAULT_TOOLSETS if tiers is None else tuple(tiers) -def _tier(safety: str) -> SafetyTier | None: - """Return the active tier named ``safety``, or ``None``.""" - return next((tier for tier in _ACTIVE_TIERS if tier.tag == safety), None) +def _tier(toolset: str) -> Toolset | None: + """Return the active entry named ``toolset``, or ``None``.""" + return next((entry for entry in _ACTIVE_TOOLSETS if entry.tag == toolset), None) -def _safety_spec(safety: str) -> BadgeSpec: - """Return the badge spec for a tier, honouring the active vocabulary.""" - tier = _tier(safety) +def active_toolsets() -> tuple[Toolset, ...]: + """Return the vocabulary in force, in the order it was declared.""" + return _ACTIVE_TOOLSETS + + +def _toolset_spec(toolset: str) -> BadgeSpec: + """Return the badge spec for a entry, honouring the active vocabulary.""" + entry = _tier(toolset) return BadgeSpec( - safety, - tooltip=(tier.tooltip if tier and tier.tooltip else f"Safety: {safety}"), - icon=(tier.icon if tier else ""), + toolset, + tooltip=(entry.tooltip if entry and entry.tooltip else f"Toolset: {toolset}"), + icon=(entry.icon if entry else ""), classes=( SAB.DENSE, SAB.NO_UNDERLINE, - _CSS.BADGE_SAFETY, - _CSS.safety_class(safety), + _CSS.BADGE_TOOLSET, + _CSS.toolset_class(toolset), ), ) @@ -60,16 +65,16 @@ def _safety_spec(safety: str) -> BadgeSpec: _TYPE_TOOLTIP = "MCP tool" -def build_safety_badge( - safety: str, +def build_toolset_badge( + toolset: str, *, icon_only: bool = False, ) -> BadgeNode: - """Build a safety tier badge. + """Build a toolset badge. Parameters ---------- - safety : str + toolset : str One of ``readonly``, ``mutating``, ``destructive``. icon_only : bool When True, create an icon-only badge (empty text, 16x16 colored box). @@ -80,16 +85,16 @@ def build_safety_badge( Examples -------- - >>> b = build_safety_badge("readonly") + >>> b = build_toolset_badge("readonly") >>> b.astext() 'readonly' """ - spec = _safety_spec(safety) + spec = _toolset_spec(toolset) style: t.Literal["full", "icon-only", "inline-icon"] = ( "icon-only" if icon_only else "full" ) return build_badge( - "" if icon_only else safety, + "" if icon_only else toolset, tooltip=spec.tooltip, icon=spec.icon, classes=list(spec.classes), @@ -113,13 +118,13 @@ def build_type_tool_badge() -> BadgeNode: ) -def build_tool_badge_group(safety: str) -> nodes.inline: - """Badge group: safety tier + type ``tool``. +def build_tool_badge_group(toolset: str) -> nodes.inline: + """Badge group: toolset entry + type ``tool``. Parameters ---------- - safety : str - Safety tier name. + toolset : str + Safety entry name. Returns ------- @@ -132,8 +137,8 @@ def build_tool_badge_group(safety: str) -> nodes.inline: True """ specs: list[BadgeSpec] = [] - if safety: - specs.append(_safety_spec(safety)) + if toolset: + specs.append(_toolset_spec(toolset)) specs.append( BadgeSpec( "tool", @@ -144,7 +149,7 @@ def build_tool_badge_group(safety: str) -> nodes.inline: return build_badge_group_from_specs(specs) -def build_toolbar(safety: str) -> nodes.inline: +def build_toolbar(toolset: str) -> nodes.inline: """Toolbar on the title row (flex ``margin-left: auto``). Examples @@ -153,7 +158,7 @@ def build_toolbar(safety: str) -> nodes.inline: >>> "gp-sphinx-toolbar" in t["classes"] True """ - return _sab_build_toolbar(build_tool_badge_group(safety)) + return _sab_build_toolbar(build_tool_badge_group(toolset)) _TYPE_TOOLTIP_PROMPT = "MCP prompt recipe" diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py index cf02c53c..46751468 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py @@ -11,15 +11,15 @@ from sphinx.application import Sphinx from sphinx_autodoc_fastmcp._models import ( - DEFAULT_SAFETY_TIERS, + DEFAULT_TOOLSETS, PromptArgInfo, PromptInfo, ResourceInfo, ResourceTemplateInfo, - SafetyTier, ToolInfo, - coerce_safety_tiers, - resolve_safety, + Toolset, + coerce_toolsets, + resolve_toolset, ) from sphinx_autodoc_fastmcp._parsing import extract_params, first_paragraph from sphinx_autodoc_typehints_gp import normalize_annotation_text @@ -34,12 +34,12 @@ def __init__( self, *, area_map: dict[str, str], - safety_tiers: tuple[SafetyTier, ...] = DEFAULT_SAFETY_TIERS, + toolsets: tuple[Toolset, ...] = DEFAULT_TOOLSETS, ) -> None: self.tools: list[ToolInfo] = [] self._current_module: str = "" self._area_map = area_map - self.safety_tiers = safety_tiers + self.toolsets = toolsets def tool( self, @@ -52,7 +52,7 @@ def tool( tags = tags or set() def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: - safety = resolve_safety(tags, self.safety_tiers) + toolset = resolve_toolset(tags, self.toolsets) module_name = self._current_module area = self._area_map.get( @@ -66,7 +66,7 @@ def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: title=title or func.__name__.replace("_", " ").title(), module_name=module_name, area=area, - safety=safety, + toolset=toolset, annotations=annotations, func=func, docstring=func.__doc__ or "", @@ -86,7 +86,7 @@ def _tool_from_callable( *, module_name: str, area_map: dict[str, str], - safety_tiers: tuple[SafetyTier, ...] = DEFAULT_SAFETY_TIERS, + toolsets: tuple[Toolset, ...] = DEFAULT_TOOLSETS, ) -> ToolInfo | None: """Build ``ToolInfo`` from a decorated function (``__fastmcp__``).""" meta = getattr(func, "__fastmcp__", None) @@ -95,7 +95,7 @@ def _tool_from_callable( tags = getattr(meta, "tags", None) or set() if not isinstance(tags, set): tags = set(tags) if tags else set() - safety = resolve_safety(tags, safety_tiers) + toolset = resolve_toolset(tags, toolsets) area = area_map.get(module_name, module_name.replace("_tools", "")) name = getattr(meta, "name", None) or func.__name__ title = getattr(meta, "title", None) or name.replace("_", " ").title() @@ -116,7 +116,7 @@ def _tool_from_callable( title=title, module_name=module_name, area=area, - safety=safety, + toolset=toolset, annotations=ann_dict, func=func, docstring=func.__doc__ or "", @@ -131,7 +131,7 @@ def collect_tools(app: Sphinx) -> None: """Populate ``app.env.fastmcp_tools`` from configured modules.""" modules: list[str] = list(app.config.fastmcp_tool_modules) area_map: dict[str, str] = dict(app.config.fastmcp_area_map) - safety_tiers = coerce_safety_tiers(app.config.fastmcp_safety_tiers) + toolsets = coerce_toolsets(app.config.fastmcp_toolsets) mode = str(app.config.fastmcp_collector_mode) if mode not in ("register", "introspect"): logger.warning( @@ -150,7 +150,7 @@ def collect_tools(app: Sphinx) -> None: collector_tools: list[ToolInfo] = [] if mode == "register": - collector = ToolCollector(area_map=area_map, safety_tiers=safety_tiers) + collector = ToolCollector(area_map=area_map, toolsets=toolsets) for dotted in modules: mod_suffix = dotted.split(".")[-1] collector._current_module = mod_suffix @@ -184,7 +184,7 @@ def collect_tools(app: Sphinx) -> None: obj, module_name=mod_suffix, area_map=area_map, - safety_tiers=safety_tiers, + toolsets=toolsets, ) if info is not None: collector_tools.append(info) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py index 8aed7a7b..45b74c29 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py @@ -1,7 +1,7 @@ """CSS class name constants for sphinx_autodoc_fastmcp. All constants use the ``gp-sphinx-fastmcp`` namespace for FastMCP-specific -layout and safety semantics. For shared badge primitives, import ``SAB`` +layout and toolset semantics. For shared badge primitives, import ``SAB`` from ``sphinx_ux_badges`` directly. Examples @@ -9,8 +9,8 @@ >>> _CSS.TOOL_SECTION 'gp-sphinx-fastmcp__tool-section' ->>> _CSS.BADGE_SAFETY -'gp-sphinx-fastmcp__safety' +>>> _CSS.BADGE_TOOLSET +'gp-sphinx-fastmcp__toolset' """ from __future__ import annotations @@ -40,19 +40,19 @@ class _CSS: RESOURCE_SIGNATURE = "gp-sphinx-fastmcp__resource-signature" BODY_SECTION = "gp-sphinx-fastmcp__body-section" - # Safety slot + tier values - BADGE_SAFETY = "gp-sphinx-fastmcp__safety" - SAFETY_READONLY = "gp-sphinx-fastmcp__safety-readonly" - SAFETY_MUTATING = "gp-sphinx-fastmcp__safety-mutating" - SAFETY_DESTRUCTIVE = "gp-sphinx-fastmcp__safety-destructive" + # Toolset slot + values + BADGE_TOOLSET = "gp-sphinx-fastmcp__toolset" + TOOLSET_INSPECT = "gp-sphinx-fastmcp__toolset-readonly" + TOOLSET_MANAGE = "gp-sphinx-fastmcp__toolset-mutating" + TOOLSET_TEARDOWN = "gp-sphinx-fastmcp__toolset-destructive" @staticmethod - def safety_class(safety: str) -> str: - """Return safety modifier class for badge styling. + def toolset_class(toolset: str) -> str: + """Return toolset modifier class for badge styling. Examples -------- - >>> _CSS.safety_class("readonly") - 'gp-sphinx-fastmcp__safety-readonly' + >>> _CSS.toolset_class("readonly") + 'gp-sphinx-fastmcp__toolset-readonly' """ - return f"gp-sphinx-fastmcp__safety-{safety}" + return f"gp-sphinx-fastmcp__toolset-{toolset}" diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py index 92788a8b..0cd7f6be 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py @@ -13,6 +13,7 @@ from sphinx.environment import BuildEnvironment from sphinx_autodoc_fastmcp._badges import ( + active_toolsets, build_prompt_badge_group, build_resource_badge_group, build_tool_badge_group, @@ -284,7 +285,7 @@ def _build_tool_section(self, tool: ToolInfo) -> list[nodes.Node]: profile_class=API.profile("fastmcp-tool"), signature_children=(nodes.literal("", tool.name),), content_children=tuple(content_nodes), - badge_group=build_tool_badge_group(tool.safety), + badge_group=build_tool_badge_group(tool.toolset), permalink=link, entry_classes=(_CSS.TOOL_ENTRY,), signature_classes=(_CSS.TOOL_SIGNATURE,), @@ -378,14 +379,14 @@ def _build_description(self, p: ParamInfo) -> nodes.paragraph: class FastMCPToolSummaryDirective(SphinxDirective): - """Summary tables of tools grouped by safety tier.""" + """Summary tables of tools grouped by toolset.""" required_arguments = 0 optional_arguments = 0 has_content = False def run(self) -> list[nodes.Node]: - """Build tier sections with tables.""" + """Build entry sections with tables.""" tools: dict[str, ToolInfo] = getattr(self.env, "fastmcp_tools", {}) if not tools: @@ -402,20 +403,16 @@ def run(self) -> list[nodes.Node]: "destructive": [], } for tool in tools.values(): - groups.setdefault(tool.safety, []).append(tool) + groups.setdefault(tool.toolset, []).append(tool) result_nodes: list[nodes.Node] = [] - tier_order = [ - ("readonly", "Inspect", "Read state without changing anything."), - ("mutating", "Act", "Create or modify objects."), - ("destructive", "Destroy", "Remove objects; not reversible."), - ] - - for safety, label, desc in tier_order: - tier_tools = groups.get(safety, []) - if not tier_tools: + for toolset in active_toolsets(): + group_tools = groups.get(toolset.tag, []) + if not group_tools: continue + label = toolset.tag.replace("_", " ").title() + desc = toolset.tooltip section = nodes.section() section["ids"].append(label.lower()) @@ -425,7 +422,7 @@ def run(self) -> list[nodes.Node]: headers = ["Tool", "Description"] rows: list[list[str | nodes.Node]] = [] - for tool in sorted(tier_tools, key=lambda x: x.name): + for tool in sorted(group_tools, key=lambda x: x.name): first_line = first_paragraph(tool.docstring) ref = nodes.reference("", "", internal=True) ref["refuri"] = f"{tool.area}/#{_component_ids('tool', tool.name)[0]}" diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py index 7b9fbd07..e17bf11d 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py @@ -7,7 +7,7 @@ @dataclass(frozen=True) -class SafetyTier: +class Toolset: """One entry in the vocabulary a project tags its tools with. Attributes @@ -25,32 +25,20 @@ class SafetyTier: icon: str = "" -#: The vocabulary assumed when a project declares none, in precedence -#: order. Matches the tags FastMCP projects have used since this -#: extension shipped, so an existing docs build renders unchanged. -DEFAULT_SAFETY_TIERS: tuple[SafetyTier, ...] = ( - SafetyTier( - "destructive", - "Destructive \u2014 may remove data; not reversible", - "\U0001f4a3", - ), - SafetyTier( - "mutating", "Mutating \u2014 creates or modifies objects", "\u270f\ufe0f" - ), - SafetyTier( - "readonly", - "Read-only \u2014 does not modify external state", - "\U0001f50d", - ), -) - - -def coerce_safety_tiers(value: t.Any) -> tuple[SafetyTier, ...]: - """Return a tier vocabulary from a ``fastmcp_safety_tiers`` value. +#: No vocabulary is assumed. This extension renders documentation for +#: projects whose tags it does not choose, so shipping a default would +#: badge one project's tools with another's words. A project declares +#: ``fastmcp_toolsets``; until it does, tools render without a toolset +#: badge. +DEFAULT_TOOLSETS: tuple[Toolset, ...] = () + + +def coerce_toolsets(value: t.Any) -> tuple[Toolset, ...]: + """Return a entry vocabulary from a ``fastmcp_toolsets`` value. Accepts what a ``conf.py`` can express: a sequence of mappings, of - :class:`SafetyTier`, or of bare tag strings. An empty value means the - project declared none, so :data:`DEFAULT_SAFETY_TIERS` applies. + :class:`Toolset`, or of bare tag strings. An empty value declares no + vocabulary, and tools then carry no toolset badge. Parameters ---------- @@ -59,27 +47,27 @@ def coerce_safety_tiers(value: t.Any) -> tuple[SafetyTier, ...]: Returns ------- - tuple of SafetyTier + tuple of Toolset Vocabulary in precedence order, highest first. Examples -------- - >>> coerce_safety_tiers(())[0].tag - 'destructive' - >>> [tier.tag for tier in coerce_safety_tiers(("execute", "inspect"))] + >>> coerce_toolsets(()) + () + >>> [entry.tag for entry in coerce_toolsets(("execute", "inspect"))] ['execute', 'inspect'] """ if not value: - return DEFAULT_SAFETY_TIERS - tiers: list[SafetyTier] = [] + return DEFAULT_TOOLSETS + tiers: list[Toolset] = [] for entry in value: - if isinstance(entry, SafetyTier): + if isinstance(entry, Toolset): tiers.append(entry) elif isinstance(entry, str): - tiers.append(SafetyTier(entry)) + tiers.append(Toolset(entry)) else: tiers.append( - SafetyTier( + Toolset( entry["tag"], entry.get("tooltip", ""), entry.get("icon", ""), @@ -88,21 +76,21 @@ def coerce_safety_tiers(value: t.Any) -> tuple[SafetyTier, ...]: return tuple(tiers) -def resolve_safety( +def resolve_toolset( tags: t.Iterable[str], - tiers: t.Sequence[SafetyTier] = DEFAULT_SAFETY_TIERS, + tiers: t.Sequence[Toolset] = DEFAULT_TOOLSETS, ) -> str: - """Return the tier a tool's tags place it in, highest precedence first. + """Return the entry a tool's tags place it in, highest precedence first. - Returns the empty string when no tag matches. Naming a default tier - here would report a tool as belonging to a tier nobody assigned it - to, which is the one answer a badge must never give. + Returns the empty string when no tag matches. Naming a fallback here + would report a tool as belonging to a toolset nobody assigned it to, + which is the one answer a badge must never give. Parameters ---------- tags : iterable of str The tool's tags. - tiers : sequence of SafetyTier + tiers : sequence of Toolset Vocabulary in precedence order. Returns @@ -112,15 +100,17 @@ def resolve_safety( Examples -------- - >>> resolve_safety({"mutating"}) - 'mutating' - >>> resolve_safety({"execute"}) + >>> from sphinx_autodoc_fastmcp._models import coerce_toolsets + >>> tiers = coerce_toolsets(("execute", "inspect")) + >>> resolve_toolset({"execute"}, tiers) + 'execute' + >>> resolve_toolset({"unknown"}, tiers) '' """ present = set(tags) - for tier in tiers: - if tier.tag in present: - return tier.tag + for entry in tiers: + if entry.tag in present: + return entry.tag return "" @@ -166,8 +156,8 @@ class ToolInfo: area : str Grouping key for the tool, taken from ``fastmcp_area_map`` or derived from the module name. - safety : str - Risk tier read from the tool's tags — ``"readonly"``, + toolset : str + Toolset read from the tool's tags — ``"readonly"``, ``"mutating"``, or ``"destructive"``. annotations : dict[str, bool] MCP hint flags such as ``readOnlyHint`` and ``destructiveHint``, @@ -187,7 +177,7 @@ class ToolInfo: title: str module_name: str area: str - safety: str + toolset: str annotations: dict[str, bool] func: t.Callable[..., t.Any] docstring: str diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_prototype.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_prototype.py index b302ac82..826eb3cf 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_prototype.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_prototype.py @@ -12,7 +12,7 @@ ... title="List Sessions", ... module_name="demo_tools", ... area="api", -... safety="readonly", +... toolset="readonly", ... annotations={}, ... func=lambda server: "[]", ... docstring="List sessions for one server.", @@ -134,7 +134,7 @@ def build_tool_desc_prototype(tool: ToolInfo) -> addnodes.desc: inject_signature_slots( signature, marker_attr="smf_prototype_slots", - badge_node=build_tool_badge_group(tool.safety), + badge_node=build_tool_badge_group(tool.toolset), extract_source_link=False, ) desc += signature diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py index 7d7183d0..460f0edc 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py @@ -92,7 +92,7 @@ def _make_component_ref_role( """Create a resource/prompt cross-reference role callable. The role renders an inline code literal linked to the component card; it - carries no safety badge (only tools have a safety tier). + carries no toolset badge (only tools have a toolset entry). Parameters ---------- diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css index e84c0e58..d7165b99 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css @@ -1,8 +1,8 @@ /* sphinx_autodoc_fastmcp — color layer for FastMCP tool badges. * * Base metrics and sizing come from sphinx_ux_badges.css (gp-sphinx-badge--dense class). - * This file matches sphinx-gp-theme custom.css rules for .sd-badge[aria-label^="Safety tier:"] - * (colors and theme --gp-sphinx-fastmcp-safety-* variables). + * This file matches sphinx-gp-theme custom.css rules for .sd-badge[aria-label^="Safety entry:"] + * (colors and theme --gp-sphinx-fastmcp-toolset-* variables). * * Safety palette: same tokens as sphinx_gp_theme/theme/static/css/custom.css * so readonly / mutating / destructive match production regardless of load order. @@ -12,16 +12,16 @@ */ :root { - /* ── Safety tier (kept in sync with sphinx_gp_theme/custom.css) ── */ - --gp-sphinx-fastmcp-safety-readonly-bg: #1f7a3f; - --gp-sphinx-fastmcp-safety-readonly-border: #2a8d4d; - --gp-sphinx-fastmcp-safety-readonly-text: #f3fff7; - --gp-sphinx-fastmcp-safety-mutating-bg: #b96a1a; - --gp-sphinx-fastmcp-safety-mutating-border: #cf7a23; - --gp-sphinx-fastmcp-safety-mutating-text: #fff8ef; - --gp-sphinx-fastmcp-safety-destructive-bg: #b4232c; - --gp-sphinx-fastmcp-safety-destructive-border: #cb3640; - --gp-sphinx-fastmcp-safety-destructive-text: #fff5f5; + /* ── Safety entry (kept in sync with sphinx_gp_theme/custom.css) ── */ + --gp-sphinx-fastmcp-toolset-readonly-bg: #1f7a3f; + --gp-sphinx-fastmcp-toolset-readonly-border: #2a8d4d; + --gp-sphinx-fastmcp-toolset-readonly-text: #f3fff7; + --gp-sphinx-fastmcp-toolset-mutating-bg: #b96a1a; + --gp-sphinx-fastmcp-toolset-mutating-border: #cf7a23; + --gp-sphinx-fastmcp-toolset-mutating-text: #fff8ef; + --gp-sphinx-fastmcp-toolset-destructive-bg: #b4232c; + --gp-sphinx-fastmcp-toolset-destructive-border: #cb3640; + --gp-sphinx-fastmcp-toolset-destructive-text: #fff5f5; /* ── Type: tool — teal ── */ --gp-sphinx-fastmcp-type-tool-bg: #0e7490; @@ -50,30 +50,30 @@ } /* ── Safety badges: gp-sphinx-badge--dense provides compact metrics; restore inline-flex for icon gap ── */ -.gp-sphinx-badge.gp-sphinx-badge--dense.gp-sphinx-fastmcp__safety { +.gp-sphinx-badge.gp-sphinx-badge--dense.gp-sphinx-fastmcp__toolset { display: inline-flex !important; } /* - * Matte safety colors: literal hex + !important so sphinx-design (loaded after + * Matte toolset colors: literal hex + !important so sphinx-design (loaded after * this file) cannot skew var() resolution or shorthands. Keeps parity with - * :root --gp-sphinx-fastmcp-safety-* above; override there + copy here if you change the palette. + * :root --gp-sphinx-fastmcp-toolset-* above; override there + copy here if you change the palette. */ -.gp-sphinx-badge.gp-sphinx-fastmcp__safety-readonly:not(.gp-sphinx-badge--inline-icon) { +.gp-sphinx-badge.gp-sphinx-fastmcp__toolset-readonly:not(.gp-sphinx-badge--inline-icon) { background-color: #1f7a3f !important; color: #f3fff7 !important; border: 1px solid #2a8d4d !important; box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } -.gp-sphinx-badge.gp-sphinx-fastmcp__safety-mutating:not(.gp-sphinx-badge--inline-icon) { +.gp-sphinx-badge.gp-sphinx-fastmcp__toolset-mutating:not(.gp-sphinx-badge--inline-icon) { background-color: #b96a1a !important; color: #fff8ef !important; border: 1px solid #cf7a23 !important; box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } -.gp-sphinx-badge.gp-sphinx-fastmcp__safety-destructive:not(.gp-sphinx-badge--inline-icon) { +.gp-sphinx-badge.gp-sphinx-fastmcp__toolset-destructive:not(.gp-sphinx-badge--inline-icon) { background-color: #b4232c !important; color: #fff5f5 !important; border: 1px solid #cb3640 !important; @@ -165,31 +165,31 @@ body[data-theme="dark"] { --gp-sphinx-fastmcp-mime-border: #4b5563; } -/* Safety dark-mode box-shadow (safety badges intentionally keep hardcoded hex — see note above) */ +/* Safety dark-mode box-shadow (toolset badges intentionally keep hardcoded hex — see note above) */ @media (prefers-color-scheme: dark) { - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__safety-readonly:not(.gp-sphinx-badge--inline-icon), - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__safety-mutating:not(.gp-sphinx-badge--inline-icon), - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__safety-destructive:not(.gp-sphinx-badge--inline-icon) { + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__toolset-readonly:not(.gp-sphinx-badge--inline-icon), + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__toolset-mutating:not(.gp-sphinx-badge--inline-icon), + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__toolset-destructive:not(.gp-sphinx-badge--inline-icon) { box-shadow: var(--gp-sphinx-badge-buff-shadow-dark-ui) !important; } } -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__safety-readonly:not(.gp-sphinx-badge--inline-icon), -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__safety-mutating:not(.gp-sphinx-badge--inline-icon), -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__safety-destructive:not(.gp-sphinx-badge--inline-icon) { +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__toolset-readonly:not(.gp-sphinx-badge--inline-icon), +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__toolset-mutating:not(.gp-sphinx-badge--inline-icon), +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__toolset-destructive:not(.gp-sphinx-badge--inline-icon) { box-shadow: var(--gp-sphinx-badge-buff-shadow-dark-ui) !important; } /* ── Emoji fallback (CSS ::before) when data-icon absent ── */ -.gp-sphinx-fastmcp__safety-readonly:not([data-icon])::before { +.gp-sphinx-fastmcp__toolset-readonly:not([data-icon])::before { content: "\1F50D"; } -.gp-sphinx-fastmcp__safety-mutating:not([data-icon])::before { +.gp-sphinx-fastmcp__toolset-mutating:not([data-icon])::before { content: "\270F\FE0F"; } -.gp-sphinx-fastmcp__safety-destructive:not([data-icon])::before { +.gp-sphinx-fastmcp__toolset-destructive:not([data-icon])::before { content: "\1F4A3"; } diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py index c76aa9ab..39326cf8 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py @@ -8,7 +8,7 @@ from docutils import nodes from sphinx.application import Sphinx -from sphinx_autodoc_fastmcp._badges import build_safety_badge +from sphinx_autodoc_fastmcp._badges import build_toolset_badge from sphinx_autodoc_fastmcp._css import _CSS from sphinx_autodoc_fastmcp._models import ToolInfo from sphinx_autodoc_fastmcp._roles import ( @@ -113,7 +113,7 @@ def add_section_badges( doctree: nodes.document, fromdocname: str, ) -> None: - """Add safety badges to tier headings on configured pages.""" + """Add toolset badges to entry headings on configured pages.""" pages: set[str] = set(app.config.fastmcp_section_badge_pages) badge_map: dict[str, str] = dict(app.config.fastmcp_section_badge_map) if fromdocname not in pages: @@ -123,20 +123,20 @@ def add_section_badges( continue title_text = section[0].astext().strip() - safety = badge_map.get(title_text) - if safety is not None: + toolset = badge_map.get(title_text) + if toolset is not None: section[0] += nodes.Text(" ") - section[0] += build_safety_badge(safety) + section[0] += build_toolset_badge(toolset) continue m = re.match(r"^(\w+)\s*\((\w+)\)$", title_text) if m: - heading, tier = m.group(1), m.group(2) - if heading in badge_map and tier == badge_map[heading]: + heading, entry = m.group(1), m.group(2) + if heading in badge_map and entry == badge_map[heading]: title_node = section[0] title_node.clear() title_node += nodes.Text(heading + " ") - title_node += build_safety_badge(tier) + title_node += build_toolset_badge(entry) def resolve_tool_refs( @@ -181,7 +181,7 @@ def resolve_tool_refs( badge = None if tool_info: style = "inline-icon" if icon_pos.startswith("inline") else "icon-only" - badge = build_safety_badge(tool_info.safety, icon_only=True) + badge = build_toolset_badge(tool_info.toolset, icon_only=True) if style == "inline-icon": badge["classes"].append(SAB.INLINE_ICON) @@ -211,7 +211,7 @@ def resolve_tool_refs( tool_info = tool_data.get(tool_name) if tool_info: newnode += nodes.Text(" ") - newnode += build_safety_badge(tool_info.safety) + newnode += build_toolset_badge(tool_info.toolset) node.replace_self(newnode) @@ -229,8 +229,8 @@ def resolve_component_refs( ) -> None: """Resolve ``:resource:`` / ``:resourceref:`` / ``:prompt:`` / ``:promptref:``. - Mirrors :func:`resolve_tool_refs` without the safety-badge branches: - resources and prompts have no safety tier, so each placeholder becomes a + Mirrors :func:`resolve_tool_refs` without the toolset-badge branches: + resources and prompts have no toolset entry, so each placeholder becomes a plain inline reference (``reference`` wrapping ``literal``). ``{resource}`` resolves against both the resource and resource-template id families so one role spelling covers both. An unresolved target degrades to a bare literal. @@ -281,5 +281,5 @@ def badge_role( options: dict[str, object] | None = None, content: list[str] | None = None, ) -> tuple[list[nodes.Node], list[nodes.system_message]]: - """Role ``:badge:`readonly``` → safety badge.""" - return [build_safety_badge(text.strip())], [] + """Role ``:badge:`readonly``` → toolset badge.""" + return [build_toolset_badge(text.strip())], [] diff --git a/tests/ext/fastmcp/test_fastmcp.py b/tests/ext/fastmcp/test_fastmcp.py index b34f4094..e40425e6 100644 --- a/tests/ext/fastmcp/test_fastmcp.py +++ b/tests/ext/fastmcp/test_fastmcp.py @@ -10,7 +10,7 @@ import pytest from docutils import nodes -from sphinx_autodoc_fastmcp._badges import build_safety_badge, build_tool_badge_group +from sphinx_autodoc_fastmcp._badges import build_tool_badge_group, build_toolset_badge from sphinx_autodoc_fastmcp._collector import _resolve_server_instance from sphinx_autodoc_fastmcp._css import _CSS from sphinx_autodoc_fastmcp._parsing import ( @@ -37,24 +37,24 @@ def test_badge_group_contains_tool_type() -> None: assert "tool" in badges[-1].astext() -def test_safety_badge_is_badge_node() -> None: +def test_toolset_badge_is_badge_node() -> None: """Safety badge is a BadgeNode (shared package).""" - b = build_safety_badge("mutating") + b = build_toolset_badge("mutating") assert isinstance(b, BadgeNode) assert isinstance(b, nodes.inline) assert b.astext() == "mutating" -def test_safety_badge_has_classes() -> None: +def test_toolset_badge_has_classes() -> None: """Safety badge has gp-sphinx-badge + smf safety classes.""" - b = build_safety_badge("readonly") + b = build_toolset_badge("readonly") assert "gp-sphinx-badge" in b["classes"] - assert "gp-sphinx-fastmcp__safety-readonly" in b["classes"] + assert "gp-sphinx-fastmcp__toolset-readonly" in b["classes"] -def test_safety_badge_icon_only() -> None: +def test_toolset_badge_icon_only() -> None: """Icon-only safety badge has gp-sphinx-badge--icon-only class and empty text.""" - b = build_safety_badge("readonly", icon_only=True) + b = build_toolset_badge("readonly", icon_only=True) assert "gp-sphinx-badge--icon-only" in b["classes"] assert b.astext() == "" @@ -184,33 +184,39 @@ def test_resolve_server_returns_none_when_factory_yields_non_fastmcp( assert resolved is None -def test_default_safety_vocabulary_matches_the_shipped_tiers() -> None: - """The default keeps `readonly` / `mutating` / `destructive` behaviour.""" - from sphinx_autodoc_fastmcp._models import DEFAULT_SAFETY_TIERS, resolve_safety +def test_no_vocabulary_is_assumed_until_a_project_declares_one() -> None: + """Shipping a default would badge one project's tools with another's words.""" + from sphinx_autodoc_fastmcp._models import DEFAULT_TOOLSETS, resolve_toolset - assert resolve_safety({"destructive"}, DEFAULT_SAFETY_TIERS) == "destructive" - assert resolve_safety({"mutating"}, DEFAULT_SAFETY_TIERS) == "mutating" - assert resolve_safety({"readonly"}, DEFAULT_SAFETY_TIERS) == "readonly" - # Precedence: the highest tier present wins, whatever the set order. - assert ( - resolve_safety({"readonly", "destructive"}, DEFAULT_SAFETY_TIERS) - == "destructive" - ) + assert DEFAULT_TOOLSETS == () + assert resolve_toolset({"anything"}, DEFAULT_TOOLSETS) == "" + + +def test_precedence_follows_declaration_order() -> None: + """A tool carrying several tags takes the first one declared.""" + from sphinx_autodoc_fastmcp._models import coerce_toolsets, resolve_toolset + + tiers = coerce_toolsets(("teardown", "execute", "manage", "inspect")) + assert resolve_toolset({"inspect", "teardown"}, tiers) == "teardown" + assert resolve_toolset({"manage", "inspect"}, tiers) == "manage" -def test_an_unrecognized_tag_is_not_reported_as_readonly() -> None: - """A tool tagged outside the vocabulary must not claim to be read-only.""" - from sphinx_autodoc_fastmcp._models import DEFAULT_SAFETY_TIERS, resolve_safety - assert resolve_safety({"execute"}, DEFAULT_SAFETY_TIERS) == "" - assert resolve_safety(set(), DEFAULT_SAFETY_TIERS) == "" +def test_an_unrecognized_tag_resolves_to_no_toolset() -> None: + """A tool outside the vocabulary must not be reported as inside it.""" + from sphinx_autodoc_fastmcp._models import coerce_toolsets, resolve_toolset + + tiers = coerce_toolsets(("inspect", "execute")) + + assert resolve_toolset({"mystery"}, tiers) == "" + assert resolve_toolset(set(), tiers) == "" def test_a_project_can_supply_its_own_safety_vocabulary() -> None: """A renamed tag set resolves once the project declares it.""" - from sphinx_autodoc_fastmcp._models import coerce_safety_tiers, resolve_safety + from sphinx_autodoc_fastmcp._models import coerce_toolsets, resolve_toolset - tiers = coerce_safety_tiers( + tiers = coerce_toolsets( ( { "tag": "teardown", @@ -223,9 +229,9 @@ def test_a_project_can_supply_its_own_safety_vocabulary() -> None: ) ) - assert resolve_safety({"execute"}, tiers) == "execute" - assert resolve_safety({"inspect", "teardown"}, tiers) == "teardown" - assert resolve_safety({"readonly"}, tiers) == "" + assert resolve_toolset({"execute"}, tiers) == "execute" + assert resolve_toolset({"inspect", "teardown"}, tiers) == "teardown" + assert resolve_toolset({"readonly"}, tiers) == "" assert tiers[0].tooltip == "Removes tmux objects" @@ -236,18 +242,16 @@ def test_a_tool_outside_the_vocabulary_gets_no_safety_badge() -> None: assert group.astext() == "tool" -def test_configured_tiers_supply_the_badge_tooltip_and_icon() -> None: +def test_configured_toolsets_supply_the_badge_tooltip_and_icon() -> None: """A project's own vocabulary reaches the rendered badge.""" - from sphinx_autodoc_fastmcp._badges import use_safety_tiers - from sphinx_autodoc_fastmcp._models import coerce_safety_tiers + from sphinx_autodoc_fastmcp._badges import use_toolsets + from sphinx_autodoc_fastmcp._models import coerce_toolsets - use_safety_tiers( - coerce_safety_tiers(({"tag": "execute", "tooltip": "Runs a command"},)) - ) + use_toolsets(coerce_toolsets(({"tag": "execute", "tooltip": "Runs a command"},))) try: - badge = build_safety_badge("execute") + badge = build_toolset_badge("execute") assert badge["badge_tooltip"] == "Runs a command" finally: - use_safety_tiers(None) + use_toolsets(None) - assert build_safety_badge("execute")["badge_tooltip"] == "Safety: execute" + assert build_toolset_badge("execute")["badge_tooltip"] == "Toolset: execute" diff --git a/tests/ext/fastmcp/test_fastmcp_integration.py b/tests/ext/fastmcp/test_fastmcp_integration.py index 0c52b396..5ab52403 100644 --- a/tests/ext/fastmcp/test_fastmcp_integration.py +++ b/tests/ext/fastmcp/test_fastmcp_integration.py @@ -60,6 +60,7 @@ def list_sessions(server: str, limit: int = 20) -> str: fastmcp_tool_modules = ["demo_tools"] fastmcp_area_map = {"demo_tools": "api"} + fastmcp_toolsets = ("destructive", "mutating", "readonly") fastmcp_collector_mode = "introspect" """ ) @@ -168,6 +169,7 @@ def delete_buffer(name: str) -> str: fastmcp_tool_modules = ["buffer_tools"] fastmcp_area_map = {"buffer_tools": "api"} + fastmcp_toolsets = ("destructive", "mutating", "readonly") fastmcp_collector_mode = "introspect" """ ) diff --git a/tests/ext/fastmcp/test_prototype.py b/tests/ext/fastmcp/test_prototype.py index 9ad99858..8857dddd 100644 --- a/tests/ext/fastmcp/test_prototype.py +++ b/tests/ext/fastmcp/test_prototype.py @@ -20,7 +20,7 @@ def _make_tool_info() -> ToolInfo: title="List Sessions", module_name="demo_tools", area="api", - safety="readonly", + toolset="readonly", annotations={}, func=lambda server: "[]", docstring="List sessions for one server.\n\nReturns the available sessions.", diff --git a/tests/ext/layout/__snapshots__/test_snapshots.ambr b/tests/ext/layout/__snapshots__/test_snapshots.ambr index 2e02e4e9..d2128b94 100644 --- a/tests/ext/layout/__snapshots__/test_snapshots.ambr +++ b/tests/ext/layout/__snapshots__/test_snapshots.ambr @@ -275,7 +275,7 @@ - + readonly @@ -284,7 +284,7 @@ - + readonly diff --git a/tests/ext/layout/test_snapshots.py b/tests/ext/layout/test_snapshots.py index c642f0db..32cdc3fd 100644 --- a/tests/ext/layout/test_snapshots.py +++ b/tests/ext/layout/test_snapshots.py @@ -204,7 +204,7 @@ def _make_fastmcp_tool_desc() -> addnodes.desc: title="List Sessions", module_name="demo_tools", area="api", - safety="readonly", + toolset="readonly", annotations={}, func=lambda server: "[]", docstring=( diff --git a/tests/test_docs_package_pages.py b/tests/test_docs_package_pages.py index 92739eab..daebec76 100644 --- a/tests/test_docs_package_pages.py +++ b/tests/test_docs_package_pages.py @@ -108,6 +108,7 @@ def _fastmcp_docs_page() -> str: master_doc = "api" fastmcp_tool_modules = ["fastmcp_demo_tools"] fastmcp_area_map = {{"fastmcp_demo_tools": "api"}} + fastmcp_toolsets = ("teardown", "execute", "inspect") fastmcp_collector_mode = "introspect" """ ) @@ -232,8 +233,8 @@ def test_fastmcp_docs_page_renders_live_demo_output( assert "delete_session" in fastmcp_docs_html assert "Parameters" in fastmcp_docs_html assert "Inspect" in fastmcp_docs_html - assert "Act" in fastmcp_docs_html - assert "Destroy" in fastmcp_docs_html + assert "Execute" in fastmcp_docs_html + assert "Teardown" in fastmcp_docs_html # --------------------------------------------------------------------------- From 0afe29c26f1c5c702fab932ba230c660c3f70da4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:31:16 -0500 Subject: [PATCH 04/23] fastmcp(fix[badges]): Colour toolset badges by tone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The stylesheet keyed every colour rule on `-readonly`, `-mutating` and `-destructive`. Renaming the concept moved the class prefix and left the tag half, so `__toolset-teardown` matched no rule and every badge rendered transparent with inherited link-blue — confirmed in the browser, `background-color: rgba(0, 0, 0, 0)`. A rule per tag was always going to break: this extension cannot know what a project calls its toolsets, so it can only ever style the names it guessed. what: - Ship tones — `green`, `blue`, `amber`, `red`, `slate` — and let a project map its tags onto them with `tone` - Default to `slate`: visible, and claiming nothing about a toolset this extension did not name - Drop the emoji `::before` fallbacks keyed on guessed tag names; the icon comes from the declaration - Declare tones for this project's own demo tools --- .../packages/sphinx-autodoc-fastmcp/how-to.md | 9 +- .../src/sphinx_autodoc_fastmcp/_badges.py | 1 + .../src/sphinx_autodoc_fastmcp/_css.py | 11 +++ .../src/sphinx_autodoc_fastmcp/_models.py | 7 ++ .../_static/css/sphinx_autodoc_fastmcp.css | 92 +++++++++++-------- tests/ext/fastmcp/test_fastmcp.py | 23 +++++ .../layout/__snapshots__/test_snapshots.ambr | 4 +- 7 files changed, 107 insertions(+), 40 deletions(-) diff --git a/docs/packages/sphinx-autodoc-fastmcp/how-to.md b/docs/packages/sphinx-autodoc-fastmcp/how-to.md index 115d2bae..3bdc1301 100644 --- a/docs/packages/sphinx-autodoc-fastmcp/how-to.md +++ b/docs/packages/sphinx-autodoc-fastmcp/how-to.md @@ -55,8 +55,13 @@ back to a tag nobody assigned is the one answer a badge must never give, and with no declared vocabulary that is every tool — which is the signal that the setting is missing. -Each toolset gets the CSS class `gp-sphinx-fastmcp__toolset-`. Style the -tags your project uses in your own CSS. +`tone` picks the badge colour from `green`, `blue`, `amber`, `red` and +`slate`, defaulting to `slate`. The stylesheet ships tones rather than a rule +per tag, because it cannot know what a project calls its toolsets — so a rule +per tag would only ever style the names it happened to guess. + +Each toolset also gets the class `gp-sphinx-fastmcp__toolset-`, for a +project that wants to style one of its own tags beyond the shipped tones. `sphinx_autodoc_fastmcp` automatically registers `sphinx_ux_badges`, `sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py index 1ef8f18b..868f82c9 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py @@ -58,6 +58,7 @@ def _toolset_spec(toolset: str) -> BadgeSpec: SAB.NO_UNDERLINE, _CSS.BADGE_TOOLSET, _CSS.toolset_class(toolset), + _CSS.tone_class(entry.tone if entry else "slate"), ), ) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py index 45b74c29..f1ab8f1a 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py @@ -46,6 +46,17 @@ class _CSS: TOOLSET_MANAGE = "gp-sphinx-fastmcp__toolset-mutating" TOOLSET_TEARDOWN = "gp-sphinx-fastmcp__toolset-destructive" + @staticmethod + def tone_class(tone: str) -> str: + """Return the badge colour class for a toolset's tone. + + Examples + -------- + >>> _CSS.tone_class("red") + 'gp-sphinx-fastmcp__toolset--tone-red' + """ + return f"gp-sphinx-fastmcp__toolset--tone-{tone}" + @staticmethod def toolset_class(toolset: str) -> str: """Return toolset modifier class for badge styling. diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py index e17bf11d..142b7b5f 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py @@ -18,11 +18,17 @@ class Toolset: Hover text for the badge. Falls back to ``"Safety: "``. icon : str Emoji rendered before the label. Optional. + tone : str + Badge colour: ``green``, ``blue``, ``amber``, ``red`` or + ``slate``. Defaults to ``slate``, which is visible and claims + nothing — this extension cannot know which of a project's + toolsets deserves which colour. """ tag: str tooltip: str = "" icon: str = "" + tone: str = "slate" #: No vocabulary is assumed. This extension renders documentation for @@ -71,6 +77,7 @@ def coerce_toolsets(value: t.Any) -> tuple[Toolset, ...]: entry["tag"], entry.get("tooltip", ""), entry.get("icon", ""), + entry.get("tone", "slate"), ) ) return tuple(tiers) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css index d7165b99..76c9c94f 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css @@ -5,23 +5,31 @@ * (colors and theme --gp-sphinx-fastmcp-toolset-* variables). * * Safety palette: same tokens as sphinx_gp_theme/theme/static/css/custom.css - * so readonly / mutating / destructive match production regardless of load order. + * so every toolset tone matches production regardless of load order. * * Type badge palette (tool / prompt / resource): one variable set per type; * dark mode overrides the same variables in a scoped block — no -dark suffix antipattern. */ :root { - /* ── Safety entry (kept in sync with sphinx_gp_theme/custom.css) ── */ - --gp-sphinx-fastmcp-toolset-readonly-bg: #1f7a3f; - --gp-sphinx-fastmcp-toolset-readonly-border: #2a8d4d; - --gp-sphinx-fastmcp-toolset-readonly-text: #f3fff7; - --gp-sphinx-fastmcp-toolset-mutating-bg: #b96a1a; - --gp-sphinx-fastmcp-toolset-mutating-border: #cf7a23; - --gp-sphinx-fastmcp-toolset-mutating-text: #fff8ef; - --gp-sphinx-fastmcp-toolset-destructive-bg: #b4232c; - --gp-sphinx-fastmcp-toolset-destructive-border: #cb3640; - --gp-sphinx-fastmcp-toolset-destructive-text: #fff5f5; + /* ── Toolset tones. A project maps its own tag names onto these, + * because this extension cannot know what a project calls its + * toolsets. Kept in sync with sphinx_gp_theme/custom.css. ── */ + --gp-sphinx-fastmcp-tone-green-bg: #1f7a3f; + --gp-sphinx-fastmcp-tone-green-border: #2a8d4d; + --gp-sphinx-fastmcp-tone-green-text: #f3fff7; + --gp-sphinx-fastmcp-tone-blue-bg: #1d4ed8; + --gp-sphinx-fastmcp-tone-blue-border: #2563eb; + --gp-sphinx-fastmcp-tone-blue-text: #eff6ff; + --gp-sphinx-fastmcp-tone-amber-bg: #b96a1a; + --gp-sphinx-fastmcp-tone-amber-border: #cf7a23; + --gp-sphinx-fastmcp-tone-amber-text: #fff8ef; + --gp-sphinx-fastmcp-tone-red-bg: #b4232c; + --gp-sphinx-fastmcp-tone-red-border: #cb3640; + --gp-sphinx-fastmcp-tone-red-text: #fff5f5; + --gp-sphinx-fastmcp-tone-slate-bg: #475569; + --gp-sphinx-fastmcp-tone-slate-border: #64748b; + --gp-sphinx-fastmcp-tone-slate-text: #f8fafc; /* ── Type: tool — teal ── */ --gp-sphinx-fastmcp-type-tool-bg: #0e7490; @@ -55,31 +63,51 @@ } /* - * Matte toolset colors: literal hex + !important so sphinx-design (loaded after - * this file) cannot skew var() resolution or shorthands. Keeps parity with - * :root --gp-sphinx-fastmcp-toolset-* above; override there + copy here if you change the palette. + * Matte toolset tones: literal hex + !important so sphinx-design (loaded + * after this file) cannot skew var() resolution or shorthands. Keeps parity + * with the :root --gp-sphinx-fastmcp-tone-* block above; override there and + * copy here if you change the palette. + * + * Keyed on tone rather than on a tag name: a project names its own toolsets, + * so a rule per tag would only ever style the names this file happened to + * guess. */ -.gp-sphinx-badge.gp-sphinx-fastmcp__toolset-readonly:not(.gp-sphinx-badge--inline-icon) { + +.gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-green:not(.gp-sphinx-badge--inline-icon) { background-color: #1f7a3f !important; color: #f3fff7 !important; border: 1px solid #2a8d4d !important; box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } -.gp-sphinx-badge.gp-sphinx-fastmcp__toolset-mutating:not(.gp-sphinx-badge--inline-icon) { +.gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-blue:not(.gp-sphinx-badge--inline-icon) { + background-color: #1d4ed8 !important; + color: #eff6ff !important; + border: 1px solid #2563eb !important; + box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; +} + +.gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-amber:not(.gp-sphinx-badge--inline-icon) { background-color: #b96a1a !important; color: #fff8ef !important; border: 1px solid #cf7a23 !important; box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } -.gp-sphinx-badge.gp-sphinx-fastmcp__toolset-destructive:not(.gp-sphinx-badge--inline-icon) { +.gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-red:not(.gp-sphinx-badge--inline-icon) { background-color: #b4232c !important; color: #fff5f5 !important; border: 1px solid #cb3640 !important; box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } +.gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-slate:not(.gp-sphinx-badge--inline-icon) { + background-color: #475569 !important; + color: #f8fafc !important; + border: 1px solid #64748b !important; + box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; +} + /* ── Type badges: use variables; dark mode re-declares the same vars in a scoped block ── */ .gp-sphinx-badge.gp-sphinx-fastmcp__type-tool:not(.gp-sphinx-badge--inline-icon) { @@ -165,34 +193,26 @@ body[data-theme="dark"] { --gp-sphinx-fastmcp-mime-border: #4b5563; } -/* Safety dark-mode box-shadow (toolset badges intentionally keep hardcoded hex — see note above) */ +/* Toolset dark-mode box-shadow (tones keep hardcoded hex — see note above) */ @media (prefers-color-scheme: dark) { - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__toolset-readonly:not(.gp-sphinx-badge--inline-icon), - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__toolset-mutating:not(.gp-sphinx-badge--inline-icon), - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__toolset-destructive:not(.gp-sphinx-badge--inline-icon) { + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-green:not(.gp-sphinx-badge--inline-icon), + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-blue:not(.gp-sphinx-badge--inline-icon), + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-amber:not(.gp-sphinx-badge--inline-icon), + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-red:not(.gp-sphinx-badge--inline-icon), + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-slate:not(.gp-sphinx-badge--inline-icon) { box-shadow: var(--gp-sphinx-badge-buff-shadow-dark-ui) !important; } } -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__toolset-readonly:not(.gp-sphinx-badge--inline-icon), -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__toolset-mutating:not(.gp-sphinx-badge--inline-icon), -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__toolset-destructive:not(.gp-sphinx-badge--inline-icon) { +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-green:not(.gp-sphinx-badge--inline-icon), +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-blue:not(.gp-sphinx-badge--inline-icon), +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-amber:not(.gp-sphinx-badge--inline-icon), +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-red:not(.gp-sphinx-badge--inline-icon), +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-slate:not(.gp-sphinx-badge--inline-icon) { box-shadow: var(--gp-sphinx-badge-buff-shadow-dark-ui) !important; } /* ── Emoji fallback (CSS ::before) when data-icon absent ── */ -.gp-sphinx-fastmcp__toolset-readonly:not([data-icon])::before { - content: "\1F50D"; -} - -.gp-sphinx-fastmcp__toolset-mutating:not([data-icon])::before { - content: "\270F\FE0F"; -} - -.gp-sphinx-fastmcp__toolset-destructive:not([data-icon])::before { - content: "\1F4A3"; -} - /* ── Tool section card ──────────────────────────────────── */ section.gp-sphinx-fastmcp__tool-section { padding: 0; diff --git a/tests/ext/fastmcp/test_fastmcp.py b/tests/ext/fastmcp/test_fastmcp.py index e40425e6..4f513ccc 100644 --- a/tests/ext/fastmcp/test_fastmcp.py +++ b/tests/ext/fastmcp/test_fastmcp.py @@ -255,3 +255,26 @@ def test_configured_toolsets_supply_the_badge_tooltip_and_icon() -> None: use_toolsets(None) assert build_toolset_badge("execute")["badge_tooltip"] == "Toolset: execute" + + +def test_a_toolset_badge_carries_its_declared_tone() -> None: + """Colour comes from the project's declaration, not a guessed tag name. + + The stylesheet cannot ship a rule per tag, because it does not know + what a project calls its toolsets. It ships tones instead, and the + project maps onto them. + """ + from sphinx_autodoc_fastmcp._badges import build_toolset_badge, use_toolsets + from sphinx_autodoc_fastmcp._models import coerce_toolsets + + use_toolsets(coerce_toolsets(({"tag": "teardown", "tone": "red"},))) + try: + classes = build_toolset_badge("teardown")["classes"] + assert "gp-sphinx-fastmcp__toolset--tone-red" in classes + # An undeclared toolset still gets a visible badge, claiming nothing. + assert ( + "gp-sphinx-fastmcp__toolset--tone-slate" + in build_toolset_badge("mystery")["classes"] + ) + finally: + use_toolsets(None) diff --git a/tests/ext/layout/__snapshots__/test_snapshots.ambr b/tests/ext/layout/__snapshots__/test_snapshots.ambr index d2128b94..15bfeec1 100644 --- a/tests/ext/layout/__snapshots__/test_snapshots.ambr +++ b/tests/ext/layout/__snapshots__/test_snapshots.ambr @@ -275,7 +275,7 @@ - + readonly @@ -284,7 +284,7 @@ - + readonly From a1fb4658e3a45aaa3f99157501fdbf12addfffcb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:05:31 -0500 Subject: [PATCH 05/23] fastmcp(fix[badges]): Omit the toolset badge for an untagged tool resolve_tool_refs() guarded only on the tool being known, so a tool whose tags match none of the declared fastmcp_toolsets rendered a badge with an empty label, a bare "Toolset: " tooltip and the class gp-sphinx-fastmcp__toolset- , which matches no rule. The tone class did match, so the result was a blank grey pill. build_tool_badge_group() already guards on the toolset being non-empty. Both call sites here now do the same, so an unmatched tool renders as plain text. --- .../src/sphinx_autodoc_fastmcp/_transforms.py | 4 +- tests/ext/fastmcp/test_fastmcp_integration.py | 65 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py index 39326cf8..7f3a7fd4 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py @@ -179,7 +179,7 @@ def resolve_tool_refs( if icon_pos: tool_info = tool_data.get(tool_name) badge = None - if tool_info: + if tool_info and tool_info.toolset: style = "inline-icon" if icon_pos.startswith("inline") else "icon-only" badge = build_toolset_badge(tool_info.toolset, icon_only=True) if style == "inline-icon": @@ -209,7 +209,7 @@ def resolve_tool_refs( newnode += nodes.literal("", tool_name) if show_badge: tool_info = tool_data.get(tool_name) - if tool_info: + if tool_info and tool_info.toolset: newnode += nodes.Text(" ") newnode += build_toolset_badge(tool_info.toolset) diff --git a/tests/ext/fastmcp/test_fastmcp_integration.py b/tests/ext/fastmcp/test_fastmcp_integration.py index 5ab52403..1bd82e87 100644 --- a/tests/ext/fastmcp/test_fastmcp_integration.py +++ b/tests/ext/fastmcp/test_fastmcp_integration.py @@ -314,3 +314,68 @@ def test_heading_collision_anchor_counts( """The heading owns the bare anchor; tool links target the canonical id (#48).""" html = read_output(fastmcp_heading_collision_result, "index.html") assert html.count(needle) == expected_count + + +_UNMATCHED_CONF_PY = textwrap.dedent( + """\ + from __future__ import annotations + + import sys + + sys.path.insert(0, r"__SCENARIO_SRCDIR__") + + extensions = [ + "sphinx_autodoc_fastmcp", + ] + + fastmcp_tool_modules = ["demo_tools"] + fastmcp_area_map = {"demo_tools": "api"} + fastmcp_toolsets = ("destructive", "mutating") + fastmcp_collector_mode = "introspect" + """ +) + +_UNMATCHED_INDEX_RST = textwrap.dedent( + """\ + Tools + ===== + + Use :tool:`list_sessions` for a linked badge. + + .. fastmcp-tool:: demo_tools.list_sessions + + .. fastmcp-tool-summary:: + """ +) + + +def _unmatched_scenario() -> SphinxScenario: + """Scenario whose one tool is tagged outside the declared vocabulary.""" + return SphinxScenario( + files=( + ScenarioFile("demo_tools.py", _MODULE_SOURCE), + ScenarioFile( + "conf.py", + _UNMATCHED_CONF_PY.replace( + "__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN + ), + substitute_srcdir=True, + ), + ScenarioFile("index.rst", _UNMATCHED_INDEX_RST), + ), + ) + + +@pytest.mark.integration +def test_tool_role_omits_the_badge_for_a_tag_outside_the_vocabulary( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """An empty-label badge is worse than none: a blank pill claiming nothing.""" + cache_root = tmp_path_factory.mktemp("fastmcp-unmatched-toolset") + result = build_shared_sphinx_result( + cache_root, + _unmatched_scenario(), + purge_modules=("demo_tools",), + ) + + assert "gp-sphinx-fastmcp__toolset" not in read_output(result, "index.html") From 370c1165333b8fc1e7c4d8266a45eef8601c9b85 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:06:46 -0500 Subject: [PATCH 06/23] fastmcp(refactor[summary]): Drop the vestigial toolset group seeding The summary directive seeded its groups dict with readonly, mutating and destructive, left from when those three were the whole vocabulary. The render loop keys off the declared toolsets and setdefault() creates any key on demand, so the seeding did nothing but suggest those three tags are still special. --- .../src/sphinx_autodoc_fastmcp/_directives.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py index 0cd7f6be..bd654b7b 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py @@ -397,11 +397,7 @@ def run(self) -> list[nodes.Node]: ), ] - groups: dict[str, list[ToolInfo]] = { - "readonly": [], - "mutating": [], - "destructive": [], - } + groups: dict[str, list[ToolInfo]] = {} for tool in tools.values(): groups.setdefault(tool.toolset, []).append(tool) From be79f148469d44ef656cfdebcdccd3b14295653c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:08:01 -0500 Subject: [PATCH 07/23] fastmcp(fix[summary]): Warn when a tool falls outside the vocabulary fastmcp-tool-summary groups tools by declared toolset, so a tool carrying none of the declared tags has no section to render in and disappeared from the page with no diagnostic. Since no vocabulary is assumed by default, a project that never sets fastmcp_toolsets lost the whole summary this way. It now warns and names the tools it dropped, matching the warning the directive already emits when no tools are found at all. --- .../src/sphinx_autodoc_fastmcp/_directives.py | 10 ++++++++++ tests/ext/fastmcp/test_fastmcp_integration.py | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py index bd654b7b..aa0f2fe9 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py @@ -401,6 +401,16 @@ def run(self) -> list[nodes.Node]: for tool in tools.values(): groups.setdefault(tool.toolset, []).append(tool) + unassigned = groups.get("", []) + if unassigned: + logger.warning( + "sphinx_autodoc_fastmcp: %d tool(s) carry none of the " + "declared fastmcp_toolsets tags and are omitted from " + "fastmcp-tool-summary: %s", + len(unassigned), + ", ".join(sorted(tool.name for tool in unassigned)), + ) + result_nodes: list[nodes.Node] = [] for toolset in active_toolsets(): diff --git a/tests/ext/fastmcp/test_fastmcp_integration.py b/tests/ext/fastmcp/test_fastmcp_integration.py index 1bd82e87..f7e05dc5 100644 --- a/tests/ext/fastmcp/test_fastmcp_integration.py +++ b/tests/ext/fastmcp/test_fastmcp_integration.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import textwrap import typing as t @@ -379,3 +380,22 @@ def test_tool_role_omits_the_badge_for_a_tag_outside_the_vocabulary( ) assert "gp-sphinx-fastmcp__toolset" not in read_output(result, "index.html") + + +@pytest.mark.integration +def test_the_summary_warns_when_it_drops_an_unmatched_tool( + tmp_path_factory: pytest.TempPathFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """A tool the summary cannot place must not vanish without a trace.""" + cache_root = tmp_path_factory.mktemp("fastmcp-unmatched-toolset-warn") + with caplog.at_level(logging.WARNING, logger="sphinx_autodoc_fastmcp"): + build_shared_sphinx_result( + cache_root, + _unmatched_scenario(), + purge_modules=("demo_tools",), + ) + + messages = "\n".join(record.message for record in caplog.records) + assert "omitted from fastmcp-tool-summary" in messages + assert "list_sessions" in messages From 93cdb0dedcc5c9acfd9199190d6aae8f8d7c6e4e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:09:23 -0500 Subject: [PATCH 08/23] fastmcp(fix[summary]): Anchor summary sections on the toolset tag Section ids came from the rendered heading, so they tracked display text rather than the tag: two declared tags that title-case to the same words collided, and a tag renamed for presentation moved its anchor. Keying on the tag gives each declared toolset one stable id, and make_id() keeps it a valid HTML id for tags carrying spaces or capitals. This changes existing anchors: a readonly section is now #fastmcp-toolset-readonly rather than #readonly. Links into the old form need updating. --- .../src/sphinx_autodoc_fastmcp/_directives.py | 2 +- tests/ext/fastmcp/test_fastmcp_integration.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py index aa0f2fe9..4fddcf27 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py @@ -421,7 +421,7 @@ def run(self) -> list[nodes.Node]: desc = toolset.tooltip section = nodes.section() - section["ids"].append(label.lower()) + section["ids"].append(nodes.make_id(f"fastmcp-toolset-{toolset.tag}")) self.state.document.note_explicit_target(section) section += nodes.title("", label) section += nodes.paragraph("", desc) diff --git a/tests/ext/fastmcp/test_fastmcp_integration.py b/tests/ext/fastmcp/test_fastmcp_integration.py index f7e05dc5..eba4a304 100644 --- a/tests/ext/fastmcp/test_fastmcp_integration.py +++ b/tests/ext/fastmcp/test_fastmcp_integration.py @@ -399,3 +399,13 @@ def test_the_summary_warns_when_it_drops_an_unmatched_tool( messages = "\n".join(record.message for record in caplog.records) assert "omitted from fastmcp-tool-summary" in messages assert "list_sessions" in messages + + +@pytest.mark.integration +def test_summary_sections_anchor_on_the_toolset_tag( + fastmcp_heading_collision_result: SharedSphinxResult, +) -> None: + """A tag keeps one anchor whatever its rendered heading reads.""" + html = read_output(fastmcp_heading_collision_result, "index.html") + + assert 'id="fastmcp-toolset-destructive"' in html From 96d845e27709321b8e4948557db92498fd49e64c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:10:43 -0500 Subject: [PATCH 09/23] fastmcp(feat[toolsets]): Warn on a malformed toolset entry coerce_toolsets() indexed entry["tag"] directly, so a mapping that misspelled the key aborted the build with a bare KeyError naming neither the entry nor conf.py. It also passed any tone straight through, and a tone the stylesheet has no rule for renders a badge with no background, border or text colour and nothing to point at the typo. Both now warn and carry on, as fastmcp_collector_mode already does: a tagless entry is skipped, an unknown tone falls back to slate. TONES names the set the stylesheet actually defines. --- .../src/sphinx_autodoc_fastmcp/_models.py | 29 ++++++++++++++++++- tests/ext/fastmcp/test_fastmcp.py | 26 +++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py index 142b7b5f..c075d4e9 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py @@ -2,9 +2,16 @@ from __future__ import annotations +import logging import typing as t from dataclasses import dataclass, field +logger = logging.getLogger(__name__) + +#: Tones the stylesheet defines a rule for. A tone outside this set would +#: render an uncoloured badge, so an unknown one falls back to ``slate``. +TONES: frozenset[str] = frozenset({"green", "blue", "amber", "red", "slate"}) + @dataclass(frozen=True) class Toolset: @@ -46,6 +53,10 @@ def coerce_toolsets(value: t.Any) -> tuple[Toolset, ...]: :class:`Toolset`, or of bare tag strings. An empty value declares no vocabulary, and tools then carry no toolset badge. + A mapping with no ``"tag"`` is skipped and a tone outside :data:`TONES` + falls back to ``slate``, each with a warning, so one typo in ``conf.py`` + costs a badge rather than the build. + Parameters ---------- value : object @@ -71,13 +82,29 @@ def coerce_toolsets(value: t.Any) -> tuple[Toolset, ...]: tiers.append(entry) elif isinstance(entry, str): tiers.append(Toolset(entry)) + elif "tag" not in entry: + logger.warning( + "sphinx_autodoc_fastmcp: fastmcp_toolsets entry %r has no " + "'tag'; skipping it", + entry, + ) else: + tone = entry.get("tone", "slate") + if tone not in TONES: + logger.warning( + "sphinx_autodoc_fastmcp: unknown tone %r for toolset %r; " + "using 'slate'. Known tones: %s", + tone, + entry["tag"], + ", ".join(sorted(TONES)), + ) + tone = "slate" tiers.append( Toolset( entry["tag"], entry.get("tooltip", ""), entry.get("icon", ""), - entry.get("tone", "slate"), + tone, ) ) return tuple(tiers) diff --git a/tests/ext/fastmcp/test_fastmcp.py b/tests/ext/fastmcp/test_fastmcp.py index 4f513ccc..6555bdb9 100644 --- a/tests/ext/fastmcp/test_fastmcp.py +++ b/tests/ext/fastmcp/test_fastmcp.py @@ -278,3 +278,29 @@ def test_a_toolset_badge_carries_its_declared_tone() -> None: ) finally: use_toolsets(None) + + +def test_a_toolset_entry_without_a_tag_is_skipped( + caplog: pytest.LogCaptureFixture, +) -> None: + """One typo in conf.py costs a badge, not the build.""" + from sphinx_autodoc_fastmcp._models import coerce_toolsets + + with caplog.at_level(logging.WARNING, logger="sphinx_autodoc_fastmcp._models"): + toolsets = coerce_toolsets(({"name": "execute"}, {"tag": "inspect"})) + + assert [entry.tag for entry in toolsets] == ["inspect"] + assert "has no 'tag'" in caplog.text + + +def test_an_unknown_tone_falls_back_to_slate( + caplog: pytest.LogCaptureFixture, +) -> None: + """The stylesheet has no rule for it, so the badge would render uncoloured.""" + from sphinx_autodoc_fastmcp._models import coerce_toolsets + + with caplog.at_level(logging.WARNING, logger="sphinx_autodoc_fastmcp._models"): + (entry,) = coerce_toolsets(({"tag": "teardown", "tone": "grey"},)) + + assert entry.tone == "slate" + assert "unknown tone" in caplog.text From cdf4b7b93ac9ab578c17f6a856bd60729cb283d0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:11:39 -0500 Subject: [PATCH 10/23] fastmcp(refactor[css]): Drop the unused fixed-vocabulary constants TOOLSET_INSPECT, TOOLSET_MANAGE and TOOLSET_TEARDOWN had no callers and hardcoded the readonly / mutating / destructive class names that toolset_class() replaced. Their names had stopped matching their values, so TOOLSET_INSPECT read as the inspect tag while holding gp-sphinx-fastmcp__toolset-readonly. --- .../src/sphinx_autodoc_fastmcp/_css.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py index f1ab8f1a..cacd97de 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py @@ -40,11 +40,8 @@ class _CSS: RESOURCE_SIGNATURE = "gp-sphinx-fastmcp__resource-signature" BODY_SECTION = "gp-sphinx-fastmcp__body-section" - # Toolset slot + values + # Toolset slot BADGE_TOOLSET = "gp-sphinx-fastmcp__toolset" - TOOLSET_INSPECT = "gp-sphinx-fastmcp__toolset-readonly" - TOOLSET_MANAGE = "gp-sphinx-fastmcp__toolset-mutating" - TOOLSET_TEARDOWN = "gp-sphinx-fastmcp__toolset-destructive" @staticmethod def tone_class(tone: str) -> str: From ba6fc91805b2668f6031777a8dac1252201ca060 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:12:52 -0500 Subject: [PATCH 11/23] fastmcp(fix[config]): Correct the toolset config descriptions fastmcp_toolsets told readers that leaving it empty keeps destructive, mutating and readonly. No vocabulary is assumed, so the option's own help contradicted the sentence after it and steered readers away from setting the one option that makes badges render. It also predates tone, which it never mentioned. fastmcp_section_badge_map still offered the same three tags as its example values; it now points at whatever the project declared. --- .../src/sphinx_autodoc_fastmcp/__init__.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py index ac5e8a6c..99958189 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -131,9 +131,9 @@ def setup(app: Sphinx) -> dict[str, t.Any]: "env", description=( 'Mapping of docstring section heading (e.g. ``"Inspect"``) ' - "to the toolset badge it should render with (e.g. " - '``"readonly"``, ``"mutating"``, ``"destructive"``). ' - "Drives the inline section pills next to grouped tool lists." + "to the toolset badge it should render with, one of the tags " + "declared in ``fastmcp_toolsets``. Drives the inline section " + "pills next to grouped tool lists." ), ) app.add_config_value( @@ -151,13 +151,12 @@ def setup(app: Sphinx) -> dict[str, t.Any]: (), "env", description=( - "Safety vocabulary this project tags its tools with, in " - "precedence order, highest first. Each entry is a tag name or " - 'a mapping with ``"tag"`` and optional ``"tooltip"`` / ' - '``"icon"``. Empty keeps ``destructive`` / ``mutating`` / ' - "``readonly``. A tool carrying none of these tags renders " - "without a toolset badge rather than being reported as the " - "lowest entry." + "Vocabulary this project tags its tools with, in precedence " + "order, highest first. Each entry is a tag name or a mapping " + 'with ``"tag"`` and optional ``"tooltip"`` / ``"icon"`` / ' + '``"tone"``. Empty declares no vocabulary. A tool carrying ' + "none of these tags renders without a toolset badge rather " + "than being reported as the lowest of them." ), ) app.add_config_value( From 37a1061191d28589adc9a473c2c51059301be8c2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:14:18 -0500 Subject: [PATCH 12/23] fastmcp(docs[toolsets]): Finish the safety-to-toolset rename The rename ran as a substitution without a re-read, so it produced "a entry vocabulary", "Build entry sections" and "labeling the entry as an MCP tool" while leaving the tiers and _tier identifiers behind. It also swept a comment describing another file: the stylesheet header claimed sphinx-gp-theme's custom.css matches [aria-label^="Safety entry:"], a selector in neither file, since custom.css still carries the old "Safety tier:" block. Docstrings now say toolset, identifiers follow (tiers to toolsets, _tier to _declared), and the header says what is true: green, amber and red reuse the theme's legacy hex values so a migrating project keeps its colours, while blue and slate are new here. Two docstrings also stated the vocabulary this branch removed. build_toolset_badge documented its argument as one of readonly, mutating or destructive, and Toolset.tooltip promised a "Safety: " fallback the code never emitted. tutorial.md is a user-facing page the rename never reached. --- .../sphinx-autodoc-fastmcp/tutorial.md | 7 +++-- .../src/sphinx_autodoc_fastmcp/_badges.py | 21 +++++++------- .../src/sphinx_autodoc_fastmcp/_directives.py | 2 +- .../src/sphinx_autodoc_fastmcp/_models.py | 28 +++++++++---------- .../_static/css/sphinx_autodoc_fastmcp.css | 12 ++++---- 5 files changed, 36 insertions(+), 34 deletions(-) diff --git a/docs/packages/sphinx-autodoc-fastmcp/tutorial.md b/docs/packages/sphinx-autodoc-fastmcp/tutorial.md index 36ef50ac..c9ee3866 100644 --- a/docs/packages/sphinx-autodoc-fastmcp/tutorial.md +++ b/docs/packages/sphinx-autodoc-fastmcp/tutorial.md @@ -20,7 +20,8 @@ Render one tool's parameter table: ``` ```` -Render a summary table grouped by safety tier: +Render a summary table grouped by toolset (see the how-to guide for +declaring `fastmcp_toolsets`): ````myst ```{eval-rst} @@ -35,8 +36,8 @@ Use {tool}`list_sessions` for a linked badge, or {toolref}`delete_session` for a plain inline reference. ```` -Prompts and resources have the same affordance (without a safety badge, which -only tools carry). `{resource}` resolves a fixed resource or a resource +Prompts and resources have the same affordance (without a toolset badge, +which only tools carry). `{resource}` resolves a fixed resource or a resource template by name; `{prompt}` resolves a prompt: ````myst diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py index 868f82c9..6b22a275 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py @@ -24,20 +24,20 @@ _ACTIVE_TOOLSETS: tuple[Toolset, ...] = DEFAULT_TOOLSETS -def use_toolsets(tiers: t.Sequence[Toolset] | None) -> None: +def use_toolsets(toolsets: t.Sequence[Toolset] | None) -> None: """Install the vocabulary badges render from. Parameters ---------- - tiers : sequence of Toolset or None + toolsets : sequence of Toolset or None Vocabulary for this build. ``None`` restores the default. """ global _ACTIVE_TOOLSETS - _ACTIVE_TOOLSETS = DEFAULT_TOOLSETS if tiers is None else tuple(tiers) + _ACTIVE_TOOLSETS = DEFAULT_TOOLSETS if toolsets is None else tuple(toolsets) -def _tier(toolset: str) -> Toolset | None: - """Return the active entry named ``toolset``, or ``None``.""" +def _declared(toolset: str) -> Toolset | None: + """Return the active toolset named ``toolset``, or ``None``.""" return next((entry for entry in _ACTIVE_TOOLSETS if entry.tag == toolset), None) @@ -47,8 +47,8 @@ def active_toolsets() -> tuple[Toolset, ...]: def _toolset_spec(toolset: str) -> BadgeSpec: - """Return the badge spec for a entry, honouring the active vocabulary.""" - entry = _tier(toolset) + """Return the badge spec for a toolset, honouring the active vocabulary.""" + entry = _declared(toolset) return BadgeSpec( toolset, tooltip=(entry.tooltip if entry and entry.tooltip else f"Toolset: {toolset}"), @@ -76,7 +76,8 @@ def build_toolset_badge( Parameters ---------- toolset : str - One of ``readonly``, ``mutating``, ``destructive``. + A tag from the project's ``fastmcp_toolsets``. A tag outside it + still renders, untinted and claiming nothing. icon_only : bool When True, create an icon-only badge (empty text, 16x16 colored box). @@ -104,7 +105,7 @@ def build_toolset_badge( def build_type_tool_badge() -> BadgeNode: - """Rightmost type badge labeling the entry as an MCP tool. + """Rightmost type badge labeling the component as an MCP tool. Examples -------- @@ -125,7 +126,7 @@ def build_tool_badge_group(toolset: str) -> nodes.inline: Parameters ---------- toolset : str - Safety entry name. + Toolset tag name. Returns ------- diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py index 4fddcf27..31e89e9f 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py @@ -386,7 +386,7 @@ class FastMCPToolSummaryDirective(SphinxDirective): has_content = False def run(self) -> list[nodes.Node]: - """Build entry sections with tables.""" + """Build one section of tables per declared toolset.""" tools: dict[str, ToolInfo] = getattr(self.env, "fastmcp_tools", {}) if not tools: diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py index c075d4e9..c583db7a 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py @@ -22,7 +22,7 @@ class Toolset: tag : str Tag to look for in a tool's ``tags`` set. tooltip : str - Hover text for the badge. Falls back to ``"Safety: "``. + Hover text for the badge. Falls back to ``"Toolset: "``. icon : str Emoji rendered before the label. Optional. tone : str @@ -47,7 +47,7 @@ class Toolset: def coerce_toolsets(value: t.Any) -> tuple[Toolset, ...]: - """Return a entry vocabulary from a ``fastmcp_toolsets`` value. + """Return a toolset vocabulary from a ``fastmcp_toolsets`` value. Accepts what a ``conf.py`` can express: a sequence of mappings, of :class:`Toolset`, or of bare tag strings. An empty value declares no @@ -76,12 +76,12 @@ def coerce_toolsets(value: t.Any) -> tuple[Toolset, ...]: """ if not value: return DEFAULT_TOOLSETS - tiers: list[Toolset] = [] + toolsets: list[Toolset] = [] for entry in value: if isinstance(entry, Toolset): - tiers.append(entry) + toolsets.append(entry) elif isinstance(entry, str): - tiers.append(Toolset(entry)) + toolsets.append(Toolset(entry)) elif "tag" not in entry: logger.warning( "sphinx_autodoc_fastmcp: fastmcp_toolsets entry %r has no " @@ -99,7 +99,7 @@ def coerce_toolsets(value: t.Any) -> tuple[Toolset, ...]: ", ".join(sorted(TONES)), ) tone = "slate" - tiers.append( + toolsets.append( Toolset( entry["tag"], entry.get("tooltip", ""), @@ -107,14 +107,14 @@ def coerce_toolsets(value: t.Any) -> tuple[Toolset, ...]: tone, ) ) - return tuple(tiers) + return tuple(toolsets) def resolve_toolset( tags: t.Iterable[str], - tiers: t.Sequence[Toolset] = DEFAULT_TOOLSETS, + toolsets: t.Sequence[Toolset] = DEFAULT_TOOLSETS, ) -> str: - """Return the entry a tool's tags place it in, highest precedence first. + """Return the toolset a tool's tags place it in, highest precedence first. Returns the empty string when no tag matches. Naming a fallback here would report a tool as belonging to a toolset nobody assigned it to, @@ -124,7 +124,7 @@ def resolve_toolset( ---------- tags : iterable of str The tool's tags. - tiers : sequence of Toolset + toolsets : sequence of Toolset Vocabulary in precedence order. Returns @@ -135,14 +135,14 @@ def resolve_toolset( Examples -------- >>> from sphinx_autodoc_fastmcp._models import coerce_toolsets - >>> tiers = coerce_toolsets(("execute", "inspect")) - >>> resolve_toolset({"execute"}, tiers) + >>> toolsets = coerce_toolsets(("execute", "inspect")) + >>> resolve_toolset({"execute"}, toolsets) 'execute' - >>> resolve_toolset({"unknown"}, tiers) + >>> resolve_toolset({"unknown"}, toolsets) '' """ present = set(tags) - for entry in tiers: + for entry in toolsets: if entry.tag in present: return entry.tag return "" diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css index 76c9c94f..646bd9da 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css @@ -1,11 +1,11 @@ /* sphinx_autodoc_fastmcp — color layer for FastMCP tool badges. * * Base metrics and sizing come from sphinx_ux_badges.css (gp-sphinx-badge--dense class). - * This file matches sphinx-gp-theme custom.css rules for .sd-badge[aria-label^="Safety entry:"] - * (colors and theme --gp-sphinx-fastmcp-toolset-* variables). * - * Safety palette: same tokens as sphinx_gp_theme/theme/static/css/custom.css - * so every toolset tone matches production regardless of load order. + * Tone palette: green / amber / red reuse the hex values sphinx-gp-theme's + * custom.css gives its legacy readonly / mutating / destructive block, so a + * project moving onto fastmcp_toolsets keeps the colours it had. blue and + * slate are new here and have no counterpart there. * * Type badge palette (tool / prompt / resource): one variable set per type; * dark mode overrides the same variables in a scoped block — no -dark suffix antipattern. @@ -14,7 +14,7 @@ :root { /* ── Toolset tones. A project maps its own tag names onto these, * because this extension cannot know what a project calls its - * toolsets. Kept in sync with sphinx_gp_theme/custom.css. ── */ + * toolsets. ── */ --gp-sphinx-fastmcp-tone-green-bg: #1f7a3f; --gp-sphinx-fastmcp-tone-green-border: #2a8d4d; --gp-sphinx-fastmcp-tone-green-text: #f3fff7; @@ -57,7 +57,7 @@ --gp-sphinx-fastmcp-mime-border: #d1d5db; } -/* ── Safety badges: gp-sphinx-badge--dense provides compact metrics; restore inline-flex for icon gap ── */ +/* ── Toolset badges: gp-sphinx-badge--dense provides compact metrics; restore inline-flex for icon gap ── */ .gp-sphinx-badge.gp-sphinx-badge--dense.gp-sphinx-fastmcp__toolset { display: inline-flex !important; } From 5e851b717ba864eadad03ab13bae087d6655de80 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:15:33 -0500 Subject: [PATCH 13/23] fastmcp(test[toolsets]): Pin the vocabulary across repeated builds The active vocabulary is process-global, installed from builder-inited because badges are built at call sites with no app in scope. That makes "when is it cleared?" an easy question to answer wrongly: builder-inited fires once per Sphinx app but build-finished fires after every build(), so pairing them drops the vocabulary on the first build and renders every later one with no tooltip, icon or tone. Building one app twice and asserting the badge still carries its declared tooltip and tone catches that pairing. The badge assertions run under try/finally so the test restores the default instead of leaking its vocabulary into whatever runs next. --- tests/ext/fastmcp/test_fastmcp_integration.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/ext/fastmcp/test_fastmcp_integration.py b/tests/ext/fastmcp/test_fastmcp_integration.py index eba4a304..65d52071 100644 --- a/tests/ext/fastmcp/test_fastmcp_integration.py +++ b/tests/ext/fastmcp/test_fastmcp_integration.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import pathlib import textwrap import typing as t @@ -409,3 +410,46 @@ def test_summary_sections_anchor_on_the_toolset_tag( html = read_output(fastmcp_heading_collision_result, "index.html") assert 'id="fastmcp-toolset-destructive"' in html + + +@pytest.mark.integration +def test_the_vocabulary_survives_a_second_build_of_one_app( + tmp_path: pathlib.Path, +) -> None: + """Nothing may clear the vocabulary per build. + + Sphinx emits builder-inited once per app but build-finished after + every build(), so installing on the former and clearing on the latter + leaves every rebuild badging tools with no tooltip, icon or tone. + """ + from sphinx.application import Sphinx + + from sphinx_autodoc_fastmcp._badges import build_toolset_badge, use_toolsets + + src = tmp_path / "src" + src.mkdir() + (src / "conf.py").write_text( + 'extensions = ["sphinx_autodoc_fastmcp"]\n' + 'fastmcp_toolsets = ({"tag": "execute", "tooltip": "Runs it",' + ' "tone": "red"},)\n', + ) + (src / "index.rst").write_text("Tools\n=====\n") + + app = Sphinx( + str(src), + str(src), + str(tmp_path / "out"), + str(tmp_path / "out" / ".doctrees"), + "html", + status=None, + warning=None, + ) + app.build() + app.build() + + try: + badge = build_toolset_badge("execute") + assert badge["badge_tooltip"] == "Runs it" + assert "gp-sphinx-fastmcp__toolset--tone-red" in badge["classes"] + finally: + use_toolsets(None) From d670000a13a8e31253bd1cac2e1a7e54d878e068 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:47:27 -0500 Subject: [PATCH 14/23] docs(fastmcp): Give the demo toolsets their tones The examples page declared teardown, execute and inspect without a tone, so all three rendered slate and the flagship page demonstrated the vocabulary without demonstrating the colour that comes with it. Tones follow libtmux-mcp's mapping, the same page rendered by a real consumer: teardown red, execute amber, inspect green. --- docs/conf.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 4b3e4317..d8a31492 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -113,16 +113,19 @@ fastmcp_toolsets=( { "tag": "teardown", + "tone": "red", "tooltip": "Removes objects; not reversible.", "icon": "\N{BOMB}", }, { "tag": "execute", + "tone": "amber", "tooltip": "Starts or drives a process.", "icon": "\N{PENCIL}\N{VARIATION SELECTOR-16}", }, { "tag": "inspect", + "tone": "green", "tooltip": "Reads state without changing it.", "icon": "\N{LEFT-POINTING MAGNIFYING GLASS}", }, From 7da090553aed6116e095ee810fd3140e646c7e66 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 07:25:27 -0500 Subject: [PATCH 15/23] fastmcp(refactor[css]): Resolve badge tones through variables Each tone had its own rule repeating the same four declarations with literal hexes, so the stylesheet could only colour the tones it shipped. A project wanting another had nowhere to put it. Tones are now three layers. ``:root`` names the palette, a ``--tone-`` class maps one palette entry onto the badge slot, and a single rule consumes the slot. Restyling a shipped tone means redefining three ``:root`` variables; adding one the extension never heard of means writing one class. Also adds an outline variant, since a term can now ask for one. --- .../_static/css/sphinx_autodoc_fastmcp.css | 79 +++++++++---------- 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css index 646bd9da..810c6bca 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css @@ -2,19 +2,18 @@ * * Base metrics and sizing come from sphinx_ux_badges.css (gp-sphinx-badge--dense class). * - * Tone palette: green / amber / red reuse the hex values sphinx-gp-theme's - * custom.css gives its legacy readonly / mutating / destructive block, so a - * project moving onto fastmcp_toolsets keeps the colours it had. blue and - * slate are new here and have no counterpart there. + * Tones are three layers, so a project can enter at whichever it needs: + * :root names the palette, a --tone- class maps a palette entry onto the + * badge slot, and one rule consumes the slot. Restyle a shipped tone by + * redefining its :root vars; add a tone by writing one --tone- class. * * Type badge palette (tool / prompt / resource): one variable set per type; * dark mode overrides the same variables in a scoped block — no -dark suffix antipattern. */ :root { - /* ── Toolset tones. A project maps its own tag names onto these, - * because this extension cannot know what a project calls its - * toolsets. ── */ + /* ── Tone palette. green / amber / red reuse sphinx-gp-theme's legacy + * readonly / mutating / destructive hexes. ── */ --gp-sphinx-fastmcp-tone-green-bg: #1f7a3f; --gp-sphinx-fastmcp-tone-green-border: #2a8d4d; --gp-sphinx-fastmcp-tone-green-text: #f3fff7; @@ -62,52 +61,50 @@ display: inline-flex !important; } -/* - * Matte toolset tones: literal hex + !important so sphinx-design (loaded - * after this file) cannot skew var() resolution or shorthands. Keeps parity - * with the :root --gp-sphinx-fastmcp-tone-* block above; override there and - * copy here if you change the palette. - * - * Keyed on tone rather than on a tag name: a project names its own toolsets, - * so a rule per tag would only ever style the names this file happened to - * guess. - */ +/* !important: sphinx-design loads after this file and wins the shorthand. */ -.gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-green:not(.gp-sphinx-badge--inline-icon) { - background-color: #1f7a3f !important; - color: #f3fff7 !important; - border: 1px solid #2a8d4d !important; - box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; +.gp-sphinx-fastmcp__toolset--tone-green { + --gp-sphinx-fastmcp-badge-bg: var(--gp-sphinx-fastmcp-tone-green-bg); + --gp-sphinx-fastmcp-badge-border: var(--gp-sphinx-fastmcp-tone-green-border); + --gp-sphinx-fastmcp-badge-text: var(--gp-sphinx-fastmcp-tone-green-text); } -.gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-blue:not(.gp-sphinx-badge--inline-icon) { - background-color: #1d4ed8 !important; - color: #eff6ff !important; - border: 1px solid #2563eb !important; - box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; +.gp-sphinx-fastmcp__toolset--tone-blue { + --gp-sphinx-fastmcp-badge-bg: var(--gp-sphinx-fastmcp-tone-blue-bg); + --gp-sphinx-fastmcp-badge-border: var(--gp-sphinx-fastmcp-tone-blue-border); + --gp-sphinx-fastmcp-badge-text: var(--gp-sphinx-fastmcp-tone-blue-text); } -.gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-amber:not(.gp-sphinx-badge--inline-icon) { - background-color: #b96a1a !important; - color: #fff8ef !important; - border: 1px solid #cf7a23 !important; - box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; +.gp-sphinx-fastmcp__toolset--tone-amber { + --gp-sphinx-fastmcp-badge-bg: var(--gp-sphinx-fastmcp-tone-amber-bg); + --gp-sphinx-fastmcp-badge-border: var(--gp-sphinx-fastmcp-tone-amber-border); + --gp-sphinx-fastmcp-badge-text: var(--gp-sphinx-fastmcp-tone-amber-text); } -.gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-red:not(.gp-sphinx-badge--inline-icon) { - background-color: #b4232c !important; - color: #fff5f5 !important; - border: 1px solid #cb3640 !important; - box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; +.gp-sphinx-fastmcp__toolset--tone-red { + --gp-sphinx-fastmcp-badge-bg: var(--gp-sphinx-fastmcp-tone-red-bg); + --gp-sphinx-fastmcp-badge-border: var(--gp-sphinx-fastmcp-tone-red-border); + --gp-sphinx-fastmcp-badge-text: var(--gp-sphinx-fastmcp-tone-red-text); +} + +.gp-sphinx-fastmcp__toolset--tone-slate { + --gp-sphinx-fastmcp-badge-bg: var(--gp-sphinx-fastmcp-tone-slate-bg); + --gp-sphinx-fastmcp-badge-border: var(--gp-sphinx-fastmcp-tone-slate-border); + --gp-sphinx-fastmcp-badge-text: var(--gp-sphinx-fastmcp-tone-slate-text); } -.gp-sphinx-badge.gp-sphinx-fastmcp__toolset--tone-slate:not(.gp-sphinx-badge--inline-icon) { - background-color: #475569 !important; - color: #f8fafc !important; - border: 1px solid #64748b !important; +.gp-sphinx-badge.gp-sphinx-fastmcp__toolset:not(.gp-sphinx-badge--inline-icon) { + background-color: var(--gp-sphinx-fastmcp-badge-bg, #475569) !important; + color: var(--gp-sphinx-fastmcp-badge-text, #f8fafc) !important; + border: 1px solid var(--gp-sphinx-fastmcp-badge-border, #64748b) !important; box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } +.gp-sphinx-badge.gp-sphinx-fastmcp__toolset.gp-sphinx-badge--outline:not(.gp-sphinx-badge--inline-icon) { + background-color: transparent !important; + color: var(--gp-sphinx-fastmcp-badge-border, #64748b) !important; +} + /* ── Type badges: use variables; dark mode re-declares the same vars in a scoped block ── */ .gp-sphinx-badge.gp-sphinx-fastmcp__type-tool:not(.gp-sphinx-badge--inline-icon) { From 45dbfc5bd70965983f7b04a6415155ef4411c6f9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 07:26:20 -0500 Subject: [PATCH 16/23] fastmcp(feat[axes]): Classify tools on axes a project declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One vocabulary was one badge. ``ToolInfo.toolset`` held a single string, so a tool whose tags carried two ideas — a read-only lifecycle tool, say — had to be reported as its risk or as its topic, never both. No ``conf.py`` value could fix that; the model had to change. ``fastmcp_axes`` replaces ``fastmcp_toolsets``. An axis is one independent way of classifying a tool: a tool takes at most one term per axis, and renders one badge per axis in declaration order. Each axis names where its term comes from. ``tags`` matches declared terms against the tool's tags, as before. ``annotations`` reads MCP's own ``ToolAnnotations`` — which the collector already gathered and nothing ever rendered — following the spec, so ``destructiveHint`` describes a tool only once ``readOnlyHint`` is false and an unset hint says nothing rather than defaulting. ``meta:`` reads the mapping MCP passes through to clients untouched. A term carries its own label, tooltip, icon, tone, style, fill and extra classes, so presentation is per term rather than per extension. Badges gain ``__axis-`` and ``__-`` classes, so a project can style one axis or one term without going through tones. ``fastmcp-tool-summary`` takes an optional axis name, groups by it, and anchors sections on ``#fastmcp--``. ``fastmcp_section_badge_map`` values accept ``term`` or ``axis:term``. ``ToolInfo.toolset`` becomes ``ToolInfo.axes``, and ``ToolInfo.meta`` carries the tool's ``meta`` mapping. Existing tests move to the new API; the snapshot test now pins the axes it renders under, since they are process-global and a Sphinx build earlier in the session would otherwise choose its badge text. --- .../src/sphinx_autodoc_fastmcp/__init__.py | 32 +- .../src/sphinx_autodoc_fastmcp/_badges.py | 156 +++++----- .../src/sphinx_autodoc_fastmcp/_collector.py | 87 ++++-- .../src/sphinx_autodoc_fastmcp/_css.py | 23 +- .../src/sphinx_autodoc_fastmcp/_directives.py | 49 ++- .../src/sphinx_autodoc_fastmcp/_models.py | 285 ++++++++++++------ .../src/sphinx_autodoc_fastmcp/_prototype.py | 5 +- .../src/sphinx_autodoc_fastmcp/_roles.py | 2 +- .../src/sphinx_autodoc_fastmcp/_transforms.py | 52 +++- tests/ext/fastmcp/test_fastmcp.py | 215 +++++++------ tests/ext/fastmcp/test_fastmcp_integration.py | 32 +- tests/ext/fastmcp/test_prototype.py | 3 +- .../layout/__snapshots__/test_snapshots.ambr | 4 +- tests/ext/layout/test_snapshots.py | 24 +- tests/test_docs_package_pages.py | 4 +- 15 files changed, 609 insertions(+), 364 deletions(-) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py index 99958189..f1cfe30c 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -15,7 +15,7 @@ from sphinx.application import Sphinx -from sphinx_autodoc_fastmcp._badges import use_toolsets +from sphinx_autodoc_fastmcp._badges import use_axes from sphinx_autodoc_fastmcp._collector import ( collect_prompts_and_resources, collect_tools, @@ -29,7 +29,7 @@ FastMCPToolInputDirective, FastMCPToolSummaryDirective, ) -from sphinx_autodoc_fastmcp._models import coerce_toolsets +from sphinx_autodoc_fastmcp._models import coerce_axes from sphinx_autodoc_fastmcp._roles import ( _prompt_role, _promptref_role, @@ -131,9 +131,9 @@ def setup(app: Sphinx) -> dict[str, t.Any]: "env", description=( 'Mapping of docstring section heading (e.g. ``"Inspect"``) ' - "to the toolset badge it should render with, one of the tags " - "declared in ``fastmcp_toolsets``. Drives the inline section " - "pills next to grouped tool lists." + 'to the badge it renders with, either ``"term"`` or ' + '``"axis:term"`` naming a term declared in ``fastmcp_axes``. ' + "Drives the inline section pills next to grouped tool lists." ), ) app.add_config_value( @@ -147,16 +147,18 @@ def setup(app: Sphinx) -> dict[str, t.Any]: ), ) app.add_config_value( - "fastmcp_toolsets", + "fastmcp_axes", (), "env", description=( - "Vocabulary this project tags its tools with, in precedence " - "order, highest first. Each entry is a tag name or a mapping " - 'with ``"tag"`` and optional ``"tooltip"`` / ``"icon"`` / ' - '``"tone"``. Empty declares no vocabulary. A tool carrying ' - "none of these tags renders without a toolset badge rather " - "than being reported as the lowest of them." + "Independent ways of classifying a tool, each rendering its own " + 'badge. Every entry is a mapping with ``"name"``, an optional ' + '``"source"`` (``"tags"``, ``"annotations"`` or ``"meta:"``) ' + 'and ``"terms"``. A term is a tag name or a mapping with ' + '``"term"`` and optional ``"label"`` / ``"tooltip"`` / ``"icon"`` ' + '/ ``"tone"`` / ``"style"`` / ``"fill"`` / ``"classes"``. Empty ' + "declares no axes. A tool matching no term on an axis renders no " + "badge for it rather than being reported as the lowest term." ), ) app.add_config_value( @@ -188,10 +190,10 @@ def _add_static_path(app: Sphinx) -> None: if _static_dir not in app.config.html_static_path: app.config.html_static_path.append(_static_dir) - def _install_toolsets(app: Sphinx) -> None: - use_toolsets(coerce_toolsets(app.config.fastmcp_toolsets)) + def _install_axes(app: Sphinx) -> None: + use_axes(coerce_axes(app.config.fastmcp_axes)) - app.connect("builder-inited", _install_toolsets) + app.connect("builder-inited", _install_axes) app.connect("builder-inited", _add_static_path) app.add_css_file("css/sphinx_autodoc_fastmcp.css") diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py index 6b22a275..a66158ba 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py @@ -7,7 +7,7 @@ from docutils import nodes from sphinx_autodoc_fastmcp._css import _CSS -from sphinx_autodoc_fastmcp._models import DEFAULT_TOOLSETS, Toolset +from sphinx_autodoc_fastmcp._models import DEFAULT_AXES, Axis from sphinx_ux_badges import ( SAB, BadgeNode, @@ -17,90 +17,86 @@ build_toolbar as _sab_build_toolbar, ) -#: Vocabulary in force for the current build. Badges are built from -#: several call sites that have no ``app`` in scope, so the extension -#: installs it once at ``builder-inited`` rather than threading it -#: through every one. -_ACTIVE_TOOLSETS: tuple[Toolset, ...] = DEFAULT_TOOLSETS +#: Badges are built from call sites with no ``app`` in scope, so the +#: extension installs the axes once at ``builder-inited``. +_ACTIVE_AXES: tuple[Axis, ...] = DEFAULT_AXES -def use_toolsets(toolsets: t.Sequence[Toolset] | None) -> None: - """Install the vocabulary badges render from. +def use_axes(axes: t.Sequence[Axis] | None) -> None: + """Install the axes badges render from. ``None`` restores the default.""" + global _ACTIVE_AXES + _ACTIVE_AXES = DEFAULT_AXES if axes is None else tuple(axes) + + +def active_axes() -> tuple[Axis, ...]: + """Return the axes in force, in the order they were declared.""" + return _ACTIVE_AXES - Parameters - ---------- - toolsets : sequence of Toolset or None - Vocabulary for this build. ``None`` restores the default. - """ - global _ACTIVE_TOOLSETS - _ACTIVE_TOOLSETS = DEFAULT_TOOLSETS if toolsets is None else tuple(toolsets) +def _axis(name: str) -> Axis | None: + """Return the active axis called ``name``, or ``None``.""" + return next((a for a in _ACTIVE_AXES if a.name == name), None) -def _declared(toolset: str) -> Toolset | None: - """Return the active toolset named ``toolset``, or ``None``.""" - return next((entry for entry in _ACTIVE_TOOLSETS if entry.tag == toolset), None) +_TYPE_TOOLTIP = "MCP tool" -def active_toolsets() -> tuple[Toolset, ...]: - """Return the vocabulary in force, in the order it was declared.""" - return _ACTIVE_TOOLSETS +def term_spec(axis_name: str, value: str) -> BadgeSpec: + """Return the badge spec for ``value`` on axis ``axis_name``. -def _toolset_spec(toolset: str) -> BadgeSpec: - """Return the badge spec for a toolset, honouring the active vocabulary.""" - entry = _declared(toolset) + An undeclared axis or term still renders, untinted and claiming + nothing, so a tag the project forgot to declare is visible rather than + silently dropped. + """ + axis = _axis(axis_name) + term = axis.term(value) if axis else None return BadgeSpec( - toolset, - tooltip=(entry.tooltip if entry and entry.tooltip else f"Toolset: {toolset}"), - icon=(entry.icon if entry else ""), + term.label if term and term.label else value, + tooltip=( + term.tooltip if term and term.tooltip else f"{axis_name.title()}: {value}" + ), + icon=(term.icon if term else ""), classes=( SAB.DENSE, SAB.NO_UNDERLINE, _CSS.BADGE_TOOLSET, - _CSS.toolset_class(toolset), - _CSS.tone_class(entry.tone if entry else "slate"), + _CSS.axis_class(axis_name), + _CSS.term_class(axis_name, value), + _CSS.tone_class(term.tone if term else "slate"), + *(term.classes if term else ()), ), + style=t.cast( + 't.Literal["full", "icon-only", "inline-icon"]', + term.style if term else "full", + ), + fill=t.cast('t.Literal["filled", "outline"]', term.fill if term else "filled"), ) -_TYPE_TOOLTIP = "MCP tool" - - -def build_toolset_badge( - toolset: str, +def build_axis_badge( + axis_name: str, + value: str, *, icon_only: bool = False, ) -> BadgeNode: - """Build a toolset badge. - - Parameters - ---------- - toolset : str - A tag from the project's ``fastmcp_toolsets``. A tag outside it - still renders, untinted and claiming nothing. - icon_only : bool - When True, create an icon-only badge (empty text, 16x16 colored box). - - Returns - ------- - BadgeNode + """Build one axis badge. Examples -------- - >>> b = build_toolset_badge("readonly") - >>> b.astext() + >>> build_axis_badge("risk", "readonly").astext() 'readonly' """ - spec = _toolset_spec(toolset) + spec = term_spec(axis_name, value) style: t.Literal["full", "icon-only", "inline-icon"] = ( - "icon-only" if icon_only else "full" + "icon-only" if icon_only else spec.style ) return build_badge( - "" if icon_only else toolset, + "" if icon_only else spec.text, tooltip=spec.tooltip, icon=spec.icon, classes=list(spec.classes), style=style, + fill=spec.fill, ) @@ -109,8 +105,7 @@ def build_type_tool_badge() -> BadgeNode: Examples -------- - >>> b = build_type_tool_badge() - >>> b.astext() + >>> build_type_tool_badge().astext() 'tool' """ return build_badge( @@ -120,27 +115,47 @@ def build_type_tool_badge() -> BadgeNode: ) -def build_tool_badge_group(toolset: str) -> nodes.inline: - """Badge group: toolset entry + type ``tool``. +def primary_axis(axes: dict[str, str]) -> tuple[str, str] | None: + """Return the ``(axis, term)`` a single inline badge should show. + + Inline references have room for one badge, so they take the first + declared axis the tool matched. + + Examples + -------- + >>> primary_axis({"risk": "readonly"}) + ('risk', 'readonly') + >>> primary_axis({}) is None + True + """ + for axis in _ACTIVE_AXES: + if axes.get(axis.name): + return axis.name, axes[axis.name] + return next(((n, v) for n, v in axes.items() if v), None) + - Parameters - ---------- - toolset : str - Toolset tag name. +def build_tool_badge_group(axes: dict[str, str]) -> nodes.inline: + """Badge group: one badge per matched axis, then the type badge. - Returns - ------- - nodes.inline + Axes render in declaration order, so the group reads the way the + project ordered its taxonomy. Examples -------- - >>> g = build_tool_badge_group("readonly") + >>> g = build_tool_badge_group({"risk": "readonly"}) >>> "gp-sphinx-badge-group" in g["classes"] True """ - specs: list[BadgeSpec] = [] - if toolset: - specs.append(_toolset_spec(toolset)) + specs: list[BadgeSpec] = [ + term_spec(axis.name, axes[axis.name]) + for axis in _ACTIVE_AXES + if axes.get(axis.name) + ] + specs.extend( + term_spec(name, value) + for name, value in axes.items() + if value and _axis(name) is None + ) specs.append( BadgeSpec( "tool", @@ -151,16 +166,15 @@ def build_tool_badge_group(toolset: str) -> nodes.inline: return build_badge_group_from_specs(specs) -def build_toolbar(toolset: str) -> nodes.inline: +def build_toolbar(axes: dict[str, str]) -> nodes.inline: """Toolbar on the title row (flex ``margin-left: auto``). Examples -------- - >>> t = build_toolbar("readonly") - >>> "gp-sphinx-toolbar" in t["classes"] + >>> "gp-sphinx-toolbar" in build_toolbar({"risk": "readonly"})["classes"] True """ - return _sab_build_toolbar(build_tool_badge_group(toolset)) + return _sab_build_toolbar(build_tool_badge_group(axes)) _TYPE_TOOLTIP_PROMPT = "MCP prompt recipe" diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py index 46751468..554f2d43 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py @@ -11,15 +11,15 @@ from sphinx.application import Sphinx from sphinx_autodoc_fastmcp._models import ( - DEFAULT_TOOLSETS, + DEFAULT_AXES, + Axis, PromptArgInfo, PromptInfo, ResourceInfo, ResourceTemplateInfo, ToolInfo, - Toolset, - coerce_toolsets, - resolve_toolset, + coerce_axes, + resolve_axes, ) from sphinx_autodoc_fastmcp._parsing import extract_params, first_paragraph from sphinx_autodoc_typehints_gp import normalize_annotation_text @@ -34,25 +34,29 @@ def __init__( self, *, area_map: dict[str, str], - toolsets: tuple[Toolset, ...] = DEFAULT_TOOLSETS, + axes: tuple[Axis, ...] = DEFAULT_AXES, ) -> None: self.tools: list[ToolInfo] = [] self._current_module: str = "" self._area_map = area_map - self.toolsets = toolsets + self.axes = axes def tool( self, title: str = "", annotations: dict[str, bool] | None = None, tags: set[str] | None = None, + meta: dict[str, t.Any] | None = None, ) -> t.Callable[[t.Callable[..., t.Any]], t.Callable[..., t.Any]]: """Match ``FastMCP.tool()`` decorator behavior for capture.""" annotations = annotations or {} tags = tags or set() + meta = meta or {} def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: - toolset = resolve_toolset(tags, self.toolsets) + axes = resolve_axes( + self.axes, tags=tags, annotations=annotations, meta=meta + ) module_name = self._current_module area = self._area_map.get( @@ -66,8 +70,9 @@ def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: title=title or func.__name__.replace("_", " ").title(), module_name=module_name, area=area, - toolset=toolset, + axes=axes, annotations=annotations, + meta=meta, func=func, docstring=func.__doc__ or "", params=extract_params(func), @@ -81,43 +86,63 @@ def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: return decorator +_HINTS = ("readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint") + + +def _annotation_hints(annotations: t.Any) -> dict[str, bool]: + """Return the hints a tool actually sets, dropping the unset ones. + + FastMCP accepts ``ToolAnnotations`` or a plain mapping, so read both. + + Examples + -------- + >>> _annotation_hints({"readOnlyHint": True, "openWorldHint": None}) + {'readOnlyHint': True} + >>> _annotation_hints(None) + {} + """ + if annotations is None: + return {} + hints: dict[str, bool] = {} + for key in _HINTS: + value = ( + annotations.get(key) + if isinstance(annotations, dict) + else getattr(annotations, key, None) + ) + if isinstance(value, bool): + hints[key] = value + return hints + + def _tool_from_callable( func: t.Callable[..., t.Any], *, module_name: str, area_map: dict[str, str], - toolsets: tuple[Toolset, ...] = DEFAULT_TOOLSETS, + axes: tuple[Axis, ...] = DEFAULT_AXES, ) -> ToolInfo | None: """Build ``ToolInfo`` from a decorated function (``__fastmcp__``).""" - meta = getattr(func, "__fastmcp__", None) - if meta is None: + spec = getattr(func, "__fastmcp__", None) + if spec is None: return None - tags = getattr(meta, "tags", None) or set() + tags = getattr(spec, "tags", None) or set() if not isinstance(tags, set): tags = set(tags) if tags else set() - toolset = resolve_toolset(tags, toolsets) + meta = dict(getattr(spec, "meta", None) or {}) area = area_map.get(module_name, module_name.replace("_tools", "")) - name = getattr(meta, "name", None) or func.__name__ - title = getattr(meta, "title", None) or name.replace("_", " ").title() - annotations = getattr(meta, "annotations", None) - ann_dict: dict[str, bool] = {} - if annotations is not None: - for field in ( - "readOnlyHint", - "destructiveHint", - "idempotentHint", - "openWorldHint", - ): - val = getattr(annotations, field, None) - if isinstance(val, bool): - ann_dict[field] = val + name = getattr(spec, "name", None) or func.__name__ + title = getattr(spec, "title", None) or name.replace("_", " ").title() + ann_dict = _annotation_hints(getattr(spec, "annotations", None)) + resolved = resolve_axes(axes, tags=tags, annotations=ann_dict, meta=meta) return ToolInfo( name=name, title=title, module_name=module_name, area=area, - toolset=toolset, + axes=resolved, annotations=ann_dict, + meta=meta, func=func, docstring=func.__doc__ or "", params=extract_params(func), @@ -131,7 +156,7 @@ def collect_tools(app: Sphinx) -> None: """Populate ``app.env.fastmcp_tools`` from configured modules.""" modules: list[str] = list(app.config.fastmcp_tool_modules) area_map: dict[str, str] = dict(app.config.fastmcp_area_map) - toolsets = coerce_toolsets(app.config.fastmcp_toolsets) + axes = coerce_axes(app.config.fastmcp_axes) mode = str(app.config.fastmcp_collector_mode) if mode not in ("register", "introspect"): logger.warning( @@ -150,7 +175,7 @@ def collect_tools(app: Sphinx) -> None: collector_tools: list[ToolInfo] = [] if mode == "register": - collector = ToolCollector(area_map=area_map, toolsets=toolsets) + collector = ToolCollector(area_map=area_map, axes=axes) for dotted in modules: mod_suffix = dotted.split(".")[-1] collector._current_module = mod_suffix @@ -184,7 +209,7 @@ def collect_tools(app: Sphinx) -> None: obj, module_name=mod_suffix, area_map=area_map, - toolsets=toolsets, + axes=axes, ) if info is not None: collector_tools.append(info) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py index cacd97de..a2f9ad8d 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py @@ -45,7 +45,7 @@ class _CSS: @staticmethod def tone_class(tone: str) -> str: - """Return the badge colour class for a toolset's tone. + """Return the badge colour class for a term's tone. Examples -------- @@ -55,12 +55,23 @@ def tone_class(tone: str) -> str: return f"gp-sphinx-fastmcp__toolset--tone-{tone}" @staticmethod - def toolset_class(toolset: str) -> str: - """Return toolset modifier class for badge styling. + def axis_class(axis: str) -> str: + """Return the axis modifier class. Examples -------- - >>> _CSS.toolset_class("readonly") - 'gp-sphinx-fastmcp__toolset-readonly' + >>> _CSS.axis_class("risk") + 'gp-sphinx-fastmcp__axis-risk' """ - return f"gp-sphinx-fastmcp__toolset-{toolset}" + return f"gp-sphinx-fastmcp__axis-{axis}" + + @staticmethod + def term_class(axis: str, term: str) -> str: + """Return the per-term modifier class, namespaced by axis. + + Examples + -------- + >>> _CSS.term_class("risk", "readonly") + 'gp-sphinx-fastmcp__risk-readonly' + """ + return f"gp-sphinx-fastmcp__{axis}-{term}" diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py index 31e89e9f..9f68bf5a 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py @@ -13,7 +13,7 @@ from sphinx.environment import BuildEnvironment from sphinx_autodoc_fastmcp._badges import ( - active_toolsets, + active_axes, build_prompt_badge_group, build_resource_badge_group, build_tool_badge_group, @@ -285,7 +285,7 @@ def _build_tool_section(self, tool: ToolInfo) -> list[nodes.Node]: profile_class=API.profile("fastmcp-tool"), signature_children=(nodes.literal("", tool.name),), content_children=tuple(content_nodes), - badge_group=build_tool_badge_group(tool.toolset), + badge_group=build_tool_badge_group(tool.axes), permalink=link, entry_classes=(_CSS.TOOL_ENTRY,), signature_classes=(_CSS.TOOL_SIGNATURE,), @@ -379,14 +379,18 @@ def _build_description(self, p: ParamInfo) -> nodes.paragraph: class FastMCPToolSummaryDirective(SphinxDirective): - """Summary tables of tools grouped by toolset.""" + """Summary tables of tools grouped by one axis. + + Takes an optional argument naming the axis; defaults to the first + declared one. + """ required_arguments = 0 - optional_arguments = 0 + optional_arguments = 1 has_content = False def run(self) -> list[nodes.Node]: - """Build one section of tables per declared toolset.""" + """Build one section of tables per term on the chosen axis.""" tools: dict[str, ToolInfo] = getattr(self.env, "fastmcp_tools", {}) if not tools: @@ -397,31 +401,48 @@ def run(self) -> list[nodes.Node]: ), ] + declared = active_axes() + wanted = self.arguments[0] if self.arguments else "" + axis = next( + (a for a in declared if a.name == wanted), + declared[0] if declared and not wanted else None, + ) + if axis is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-tool-summary: no axis {wanted!r} declared " + "in fastmcp_axes." + if wanted + else "fastmcp-tool-summary: fastmcp_axes declares no axes.", + line=self.lineno, + ), + ] + groups: dict[str, list[ToolInfo]] = {} for tool in tools.values(): - groups.setdefault(tool.toolset, []).append(tool) + groups.setdefault(tool.axes.get(axis.name, ""), []).append(tool) unassigned = groups.get("", []) if unassigned: logger.warning( - "sphinx_autodoc_fastmcp: %d tool(s) carry none of the " - "declared fastmcp_toolsets tags and are omitted from " - "fastmcp-tool-summary: %s", + "sphinx_autodoc_fastmcp: %d tool(s) take no term on axis %r " + "and are omitted from fastmcp-tool-summary: %s", len(unassigned), + axis.name, ", ".join(sorted(tool.name for tool in unassigned)), ) result_nodes: list[nodes.Node] = [] - for toolset in active_toolsets(): - group_tools = groups.get(toolset.tag, []) + for term in axis.terms: + group_tools = groups.get(term.term, []) if not group_tools: continue - label = toolset.tag.replace("_", " ").title() - desc = toolset.tooltip + label = term.label or term.term.replace("_", " ").title() + desc = term.tooltip section = nodes.section() - section["ids"].append(nodes.make_id(f"fastmcp-toolset-{toolset.tag}")) + section["ids"].append(nodes.make_id(f"fastmcp-{axis.name}-{term.term}")) self.state.document.note_explicit_target(section) section += nodes.title("", label) section += nodes.paragraph("", desc) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py index c583db7a..6cc12e9f 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py @@ -8,54 +8,133 @@ logger = logging.getLogger(__name__) -#: Tones the stylesheet defines a rule for. A tone outside this set would -#: render an uncoloured badge, so an unknown one falls back to ``slate``. -TONES: frozenset[str] = frozenset({"green", "blue", "amber", "red", "slate"}) +#: Sources an axis can read a tool's term from. ``tags`` matches declared +#: terms against ``tool.tags``; ``annotations`` derives one from the MCP +#: hints; ``meta:`` reads ``tool.meta[]``. +AxisSource = str @dataclass(frozen=True) -class Toolset: - """One entry in the vocabulary a project tags its tools with. +class Term: + """One value an axis can take, and how it renders. Attributes ---------- - tag : str - Tag to look for in a tool's ``tags`` set. + term : str + Value to match, and the badge label unless ``label`` overrides it. + label : str + Badge text. Defaults to ``term``. tooltip : str - Hover text for the badge. Falls back to ``"Toolset: "``. + Hover text. Falls back to ``": "``. icon : str Emoji rendered before the label. Optional. tone : str - Badge colour: ``green``, ``blue``, ``amber``, ``red`` or - ``slate``. Defaults to ``slate``, which is visible and claims - nothing — this extension cannot know which of a project's - toolsets deserves which colour. + Colour name. Any name works: the badge gets ``--tone-`` and + the stylesheet decides what that means. + style : str + ``full``, ``icon-only`` or ``inline-icon``. + fill : str + ``filled`` or ``outline``. + classes : tuple of str + Extra CSS classes, for styling this term alone. """ - tag: str + term: str + label: str = "" tooltip: str = "" icon: str = "" tone: str = "slate" + style: str = "full" + fill: str = "filled" + classes: tuple[str, ...] = () -#: No vocabulary is assumed. This extension renders documentation for -#: projects whose tags it does not choose, so shipping a default would -#: badge one project's tools with another's words. A project declares -#: ``fastmcp_toolsets``; until it does, tools render without a toolset -#: badge. -DEFAULT_TOOLSETS: tuple[Toolset, ...] = () - +@dataclass(frozen=True) +class Axis: + """One independent way of classifying a tool. -def coerce_toolsets(value: t.Any) -> tuple[Toolset, ...]: - """Return a toolset vocabulary from a ``fastmcp_toolsets`` value. + A tool takes at most one term per axis, so two axes render two badges. - Accepts what a ``conf.py`` can express: a sequence of mappings, of - :class:`Toolset`, or of bare tag strings. An empty value declares no - vocabulary, and tools then carry no toolset badge. + Attributes + ---------- + name : str + Axis identifier, used in the CSS class and the default tooltip. + source : str + Where the term comes from. See :data:`AxisSource`. + terms : tuple of Term + Vocabulary in precedence order, highest first. + """ - A mapping with no ``"tag"`` is skipped and a tone outside :data:`TONES` - falls back to ``slate``, each with a warning, so one typo in ``conf.py`` - costs a badge rather than the build. + name: str + source: AxisSource = "tags" + terms: tuple[Term, ...] = () + + def term(self, value: str) -> Term | None: + """Return the declared term named ``value``, or ``None``.""" + return next((t_ for t_ in self.terms if t_.term == value), None) + + +#: Per the MCP spec ``destructiveHint`` describes a tool only once +#: ``readOnlyHint`` is false, so the terms are ordered to read it second. +ANNOTATION_AXIS = Axis( + name="risk", + source="annotations", + terms=( + Term( + "destructive", + tooltip="Destructive \N{EM DASH} may remove data", + icon="\N{BOMB}", + tone="red", + ), + Term( + "mutating", + tooltip="Mutating \N{EM DASH} changes state additively", + icon="\N{PENCIL}\N{VARIATION SELECTOR-16}", + tone="amber", + ), + Term( + "readonly", + tooltip="Read-only \N{EM DASH} does not modify its environment", + icon="\N{LEFT-POINTING MAGNIFYING GLASS}", + tone="green", + ), + ), +) + +DEFAULT_AXES: tuple[Axis, ...] = (ANNOTATION_AXIS,) + + +def _coerce_term(value: t.Any) -> Term | None: + """Return a :class:`Term` from a string or mapping, or ``None``.""" + if isinstance(value, Term): + return value + if isinstance(value, str): + return Term(value) + term = value.get("term", value.get("tag")) + if not term: + logger.warning( + "sphinx_autodoc_fastmcp: toolset term %r has no 'term'; skipping it", + value, + ) + return None + return Term( + term, + label=value.get("label", ""), + tooltip=value.get("tooltip", ""), + icon=value.get("icon", ""), + tone=value.get("tone", "slate"), + style=value.get("style", "full"), + fill=value.get("fill", "filled"), + classes=tuple(value.get("classes", ())), + ) + + +def coerce_axes(value: t.Any) -> tuple[Axis, ...]: + """Return the axis list a ``fastmcp_axes`` value describes. + + Accepts what a ``conf.py`` can express: a sequence of :class:`Axis` or + of mappings with ``name``, optional ``source``, and ``terms``. An empty + value declares no axes, and tools then carry no badges. Parameters ---------- @@ -64,88 +143,114 @@ def coerce_toolsets(value: t.Any) -> tuple[Toolset, ...]: Returns ------- - tuple of Toolset - Vocabulary in precedence order, highest first. + tuple of Axis + Axes in declaration order; badges render in that order. Examples -------- - >>> coerce_toolsets(()) - () - >>> [entry.tag for entry in coerce_toolsets(("execute", "inspect"))] - ['execute', 'inspect'] + >>> axes = coerce_axes(({"name": "topic", "terms": ("search", "admin")},)) + >>> axes[0].name, [t.term for t in axes[0].terms] + ('topic', ['search', 'admin']) """ if not value: - return DEFAULT_TOOLSETS - toolsets: list[Toolset] = [] + return () + axes: list[Axis] = [] for entry in value: - if isinstance(entry, Toolset): - toolsets.append(entry) - elif isinstance(entry, str): - toolsets.append(Toolset(entry)) - elif "tag" not in entry: + if isinstance(entry, Axis): + axes.append(entry) + continue + name = entry.get("name") + if not name: logger.warning( - "sphinx_autodoc_fastmcp: fastmcp_toolsets entry %r has no " - "'tag'; skipping it", + "sphinx_autodoc_fastmcp: fastmcp_axes entry %r has no 'name'; " + "skipping it", entry, ) - else: - tone = entry.get("tone", "slate") - if tone not in TONES: - logger.warning( - "sphinx_autodoc_fastmcp: unknown tone %r for toolset %r; " - "using 'slate'. Known tones: %s", - tone, - entry["tag"], - ", ".join(sorted(TONES)), - ) - tone = "slate" - toolsets.append( - Toolset( - entry["tag"], - entry.get("tooltip", ""), - entry.get("icon", ""), - tone, - ) - ) - return tuple(toolsets) + continue + terms = tuple( + t_ for t_ in (_coerce_term(v) for v in entry.get("terms", ())) if t_ + ) + axes.append(Axis(name, entry.get("source", "tags"), terms)) + return tuple(axes) -def resolve_toolset( - tags: t.Iterable[str], - toolsets: t.Sequence[Toolset] = DEFAULT_TOOLSETS, -) -> str: - """Return the toolset a tool's tags place it in, highest precedence first. +def term_from_annotations(hints: dict[str, bool]) -> str: + """Return the risk term MCP's hints imply, or ``""``. - Returns the empty string when no tag matches. Naming a fallback here - would report a tool as belonging to a toolset nobody assigned it to, - which is the one answer a badge must never give. + Follows the spec: ``destructiveHint`` and ``idempotentHint`` describe a + tool only once ``readOnlyHint`` is false, and an unset hint says + nothing rather than defaulting. + + Examples + -------- + >>> term_from_annotations({"readOnlyHint": True}) + 'readonly' + >>> term_from_annotations({"readOnlyHint": False, "destructiveHint": True}) + 'destructive' + >>> term_from_annotations({"readOnlyHint": False}) + 'mutating' + >>> term_from_annotations({}) + '' + """ + read_only = hints.get("readOnlyHint") + if read_only is True: + return "readonly" + if read_only is False: + return "destructive" if hints.get("destructiveHint") else "mutating" + return "" + + +def resolve_axes( + axes: t.Sequence[Axis], + *, + tags: t.Iterable[str] = (), + annotations: dict[str, bool] | None = None, + meta: dict[str, t.Any] | None = None, +) -> dict[str, str]: + """Return the term each axis places a tool in. + + An axis with no match is left out rather than given a fallback: naming + one would report a tool as something nobody classified it as. Parameters ---------- + axes : sequence of Axis + Declared axes. tags : iterable of str The tool's tags. - toolsets : sequence of Toolset - Vocabulary in precedence order. + annotations : dict of str to bool, optional + MCP hints the tool sets. + meta : dict, optional + The tool's ``meta`` mapping. Returns ------- - str - Matching tag, or ``""`` when the tool carries none of them. + dict + Axis name to term, for axes that matched. Examples -------- - >>> from sphinx_autodoc_fastmcp._models import coerce_toolsets - >>> toolsets = coerce_toolsets(("execute", "inspect")) - >>> resolve_toolset({"execute"}, toolsets) - 'execute' - >>> resolve_toolset({"unknown"}, toolsets) - '' + >>> axes = coerce_axes(({"name": "topic", "terms": ("admin", "search")},)) + >>> resolve_axes(axes, tags={"search"}) + {'topic': 'search'} + >>> resolve_axes(axes, tags={"other"}) + {} """ present = set(tags) - for entry in toolsets: - if entry.tag in present: - return entry.tag - return "" + hints = annotations or {} + data = meta or {} + resolved: dict[str, str] = {} + for axis in axes: + if axis.source == "annotations": + value = term_from_annotations(hints) + elif axis.source.startswith("meta:"): + raw = data.get(axis.source[len("meta:") :]) + value = str(raw) if raw is not None else "" + else: + value = next((t_.term for t_ in axis.terms if t_.term in present), "") + if value: + resolved[axis.name] = value + return resolved @dataclass @@ -190,12 +295,13 @@ class ToolInfo: area : str Grouping key for the tool, taken from ``fastmcp_area_map`` or derived from the module name. - toolset : str - Toolset read from the tool's tags — ``"readonly"``, - ``"mutating"``, or ``"destructive"``. + axes : dict[str, str] + Term this tool takes on each declared axis, for axes that matched. annotations : dict[str, bool] MCP hint flags such as ``readOnlyHint`` and ``destructiveHint``, holding only the hints the tool actually sets. + meta : dict[str, t.Any] + The tool's ``meta`` mapping, which axes can read terms from. func : t.Callable[..., t.Any] The undecorated tool function, kept so the renderer can re-inspect its signature. @@ -211,8 +317,9 @@ class ToolInfo: title: str module_name: str area: str - toolset: str + axes: dict[str, str] annotations: dict[str, bool] + meta: dict[str, t.Any] func: t.Callable[..., t.Any] docstring: str params: list[ParamInfo] diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_prototype.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_prototype.py index 826eb3cf..e23f80f6 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_prototype.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_prototype.py @@ -12,7 +12,8 @@ ... title="List Sessions", ... module_name="demo_tools", ... area="api", -... toolset="readonly", +... axes={"risk": "readonly"}, +... meta={}, ... annotations={}, ... func=lambda server: "[]", ... docstring="List sessions for one server.", @@ -134,7 +135,7 @@ def build_tool_desc_prototype(tool: ToolInfo) -> addnodes.desc: inject_signature_slots( signature, marker_attr="smf_prototype_slots", - badge_node=build_tool_badge_group(tool.toolset), + badge_node=build_tool_badge_group(tool.axes), extract_source_link=False, ) desc += signature diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py index 460f0edc..dbd34f13 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py @@ -92,7 +92,7 @@ def _make_component_ref_role( """Create a resource/prompt cross-reference role callable. The role renders an inline code literal linked to the component card; it - carries no toolset badge (only tools have a toolset entry). + carries no axis badge (only tools are classified). Parameters ---------- diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py index 7f3a7fd4..4a9e6c9d 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py @@ -8,7 +8,11 @@ from docutils import nodes from sphinx.application import Sphinx -from sphinx_autodoc_fastmcp._badges import build_toolset_badge +from sphinx_autodoc_fastmcp._badges import ( + active_axes, + build_axis_badge, + primary_axis, +) from sphinx_autodoc_fastmcp._css import _CSS from sphinx_autodoc_fastmcp._models import ToolInfo from sphinx_autodoc_fastmcp._roles import ( @@ -108,12 +112,30 @@ def register_tool_labels(app: Sphinx, doctree: nodes.document) -> None: domain.labels[alias] = (docname, canonical_id, tool_name) +def _split_term(value: str) -> tuple[str, str]: + """Split a section-badge value into ``(axis, term)``. + + A bare term takes the first declared axis, so the common single-axis + project writes ``"inspect"`` rather than ``"risk:inspect"``. + + Examples + -------- + >>> _split_term("risk:readonly") + ('risk', 'readonly') + """ + axis, _, term = value.partition(":") + if term: + return axis, term + declared = active_axes() + return (declared[0].name if declared else "", value) + + def add_section_badges( app: Sphinx, doctree: nodes.document, fromdocname: str, ) -> None: - """Add toolset badges to entry headings on configured pages.""" + """Add axis badges to section headings on configured pages.""" pages: set[str] = set(app.config.fastmcp_section_badge_pages) badge_map: dict[str, str] = dict(app.config.fastmcp_section_badge_map) if fromdocname not in pages: @@ -123,10 +145,10 @@ def add_section_badges( continue title_text = section[0].astext().strip() - toolset = badge_map.get(title_text) - if toolset is not None: + mapped = badge_map.get(title_text) + if mapped is not None: section[0] += nodes.Text(" ") - section[0] += build_toolset_badge(toolset) + section[0] += build_axis_badge(*_split_term(mapped)) continue m = re.match(r"^(\w+)\s*\((\w+)\)$", title_text) @@ -136,7 +158,7 @@ def add_section_badges( title_node = section[0] title_node.clear() title_node += nodes.Text(heading + " ") - title_node += build_toolset_badge(entry) + title_node += build_axis_badge(*_split_term(entry)) def resolve_tool_refs( @@ -179,9 +201,10 @@ def resolve_tool_refs( if icon_pos: tool_info = tool_data.get(tool_name) badge = None - if tool_info and tool_info.toolset: + primary = primary_axis(tool_info.axes) if tool_info else None + if primary: style = "inline-icon" if icon_pos.startswith("inline") else "icon-only" - badge = build_toolset_badge(tool_info.toolset, icon_only=True) + badge = build_axis_badge(*primary, icon_only=True) if style == "inline-icon": badge["classes"].append(SAB.INLINE_ICON) @@ -209,9 +232,10 @@ def resolve_tool_refs( newnode += nodes.literal("", tool_name) if show_badge: tool_info = tool_data.get(tool_name) - if tool_info and tool_info.toolset: + primary = primary_axis(tool_info.axes) if tool_info else None + if primary: newnode += nodes.Text(" ") - newnode += build_toolset_badge(tool_info.toolset) + newnode += build_axis_badge(*primary) node.replace_self(newnode) @@ -229,8 +253,8 @@ def resolve_component_refs( ) -> None: """Resolve ``:resource:`` / ``:resourceref:`` / ``:prompt:`` / ``:promptref:``. - Mirrors :func:`resolve_tool_refs` without the toolset-badge branches: - resources and prompts have no toolset entry, so each placeholder becomes a + Mirrors :func:`resolve_tool_refs` without the axis-badge branches: + resources and prompts are not classified, so each placeholder becomes a plain inline reference (``reference`` wrapping ``literal``). ``{resource}`` resolves against both the resource and resource-template id families so one role spelling covers both. An unresolved target degrades to a bare literal. @@ -281,5 +305,5 @@ def badge_role( options: dict[str, object] | None = None, content: list[str] | None = None, ) -> tuple[list[nodes.Node], list[nodes.system_message]]: - """Role ``:badge:`readonly``` → toolset badge.""" - return [build_toolset_badge(text.strip())], [] + """Role ``:badge:`readonly``` or ``:badge:`risk:readonly``` → axis badge.""" + return [build_axis_badge(*_split_term(text.strip()))], [] diff --git a/tests/ext/fastmcp/test_fastmcp.py b/tests/ext/fastmcp/test_fastmcp.py index 6555bdb9..0437dab6 100644 --- a/tests/ext/fastmcp/test_fastmcp.py +++ b/tests/ext/fastmcp/test_fastmcp.py @@ -10,7 +10,7 @@ import pytest from docutils import nodes -from sphinx_autodoc_fastmcp._badges import build_tool_badge_group, build_toolset_badge +from sphinx_autodoc_fastmcp._badges import build_axis_badge, build_tool_badge_group from sphinx_autodoc_fastmcp._collector import _resolve_server_instance from sphinx_autodoc_fastmcp._css import _CSS from sphinx_autodoc_fastmcp._parsing import ( @@ -29,32 +29,33 @@ def test_css_prefix() -> None: def test_badge_group_contains_tool_type() -> None: - """Tool badge group includes safety + type badge.""" - group = build_tool_badge_group("readonly") + """Tool badge group renders the matched axes, then the type badge.""" + group = build_tool_badge_group({"risk": "readonly"}) assert "gp-sphinx-badge-group" in group["classes"] badges = list(group.findall(BadgeNode)) assert len(badges) == 2 assert "tool" in badges[-1].astext() -def test_toolset_badge_is_badge_node() -> None: +def test_axis_badge_is_badge_node() -> None: """Safety badge is a BadgeNode (shared package).""" - b = build_toolset_badge("mutating") + b = build_axis_badge("risk", "mutating") assert isinstance(b, BadgeNode) assert isinstance(b, nodes.inline) assert b.astext() == "mutating" -def test_toolset_badge_has_classes() -> None: - """Safety badge has gp-sphinx-badge + smf safety classes.""" - b = build_toolset_badge("readonly") +def test_axis_badge_has_axis_and_term_classes() -> None: + """The badge names both its axis and its term, so CSS can target either.""" + b = build_axis_badge("risk", "readonly") assert "gp-sphinx-badge" in b["classes"] - assert "gp-sphinx-fastmcp__toolset-readonly" in b["classes"] + assert "gp-sphinx-fastmcp__axis-risk" in b["classes"] + assert "gp-sphinx-fastmcp__risk-readonly" in b["classes"] -def test_toolset_badge_icon_only() -> None: - """Icon-only safety badge has gp-sphinx-badge--icon-only class and empty text.""" - b = build_toolset_badge("readonly", icon_only=True) +def test_axis_badge_icon_only() -> None: + """Icon-only badge has the icon-only class and empty text.""" + b = build_axis_badge("risk", "readonly", icon_only=True) assert "gp-sphinx-badge--icon-only" in b["classes"] assert b.astext() == "" @@ -184,123 +185,137 @@ def test_resolve_server_returns_none_when_factory_yields_non_fastmcp( assert resolved is None -def test_no_vocabulary_is_assumed_until_a_project_declares_one() -> None: - """Shipping a default would badge one project's tools with another's words.""" - from sphinx_autodoc_fastmcp._models import DEFAULT_TOOLSETS, resolve_toolset +def test_no_axes_are_assumed_until_a_project_declares_them() -> None: + """A default vocabulary would badge one project's tools with another's words.""" + from sphinx_autodoc_fastmcp._models import coerce_axes, resolve_axes - assert DEFAULT_TOOLSETS == () - assert resolve_toolset({"anything"}, DEFAULT_TOOLSETS) == "" + assert coerce_axes(()) == () + assert resolve_axes((), tags={"anything"}) == {} def test_precedence_follows_declaration_order() -> None: - """A tool carrying several tags takes the first one declared.""" - from sphinx_autodoc_fastmcp._models import coerce_toolsets, resolve_toolset + """A tool carrying several of an axis's terms takes the first declared.""" + from sphinx_autodoc_fastmcp._models import coerce_axes, resolve_axes - tiers = coerce_toolsets(("teardown", "execute", "manage", "inspect")) + axes = coerce_axes( + ({"name": "cap", "terms": ("teardown", "execute", "manage", "inspect")},) + ) - assert resolve_toolset({"inspect", "teardown"}, tiers) == "teardown" - assert resolve_toolset({"manage", "inspect"}, tiers) == "manage" + assert resolve_axes(axes, tags={"inspect", "teardown"}) == {"cap": "teardown"} + assert resolve_axes(axes, tags={"manage", "inspect"}) == {"cap": "manage"} -def test_an_unrecognized_tag_resolves_to_no_toolset() -> None: +def test_an_unrecognized_tag_takes_no_term() -> None: """A tool outside the vocabulary must not be reported as inside it.""" - from sphinx_autodoc_fastmcp._models import coerce_toolsets, resolve_toolset + from sphinx_autodoc_fastmcp._models import coerce_axes, resolve_axes + + axes = coerce_axes(({"name": "cap", "terms": ("inspect", "execute")},)) - tiers = coerce_toolsets(("inspect", "execute")) + assert resolve_axes(axes, tags={"mystery"}) == {} + assert resolve_axes(axes, tags=set()) == {} - assert resolve_toolset({"mystery"}, tiers) == "" - assert resolve_toolset(set(), tiers) == "" +def test_two_axes_classify_one_tool_independently() -> None: + """The point of axes: risk and topic can disagree without one winning. -def test_a_project_can_supply_its_own_safety_vocabulary() -> None: - """A renamed tag set resolves once the project declares it.""" - from sphinx_autodoc_fastmcp._models import coerce_toolsets, resolve_toolset + A single vocabulary forces a read-only lifecycle tool to be badged + either by its risk or by its topic, never both. + """ + from sphinx_autodoc_fastmcp._models import coerce_axes, resolve_axes - tiers = coerce_toolsets( + axes = coerce_axes( ( - { - "tag": "teardown", - "tooltip": "Removes tmux objects", - "icon": "\U0001f4a3", - }, - {"tag": "execute"}, - {"tag": "manage"}, - {"tag": "inspect"}, + {"name": "risk", "terms": ("mutating", "readonly")}, + {"name": "topic", "terms": ("lifecycle", "metrics")}, ) ) - assert resolve_toolset({"execute"}, tiers) == "execute" - assert resolve_toolset({"inspect", "teardown"}, tiers) == "teardown" - assert resolve_toolset({"readonly"}, tiers) == "" - assert tiers[0].tooltip == "Removes tmux objects" - - -def test_a_tool_outside_the_vocabulary_gets_no_safety_badge() -> None: - """No badge is honest; a badge naming a tier nobody assigned is not.""" - group = build_tool_badge_group("") - - assert group.astext() == "tool" - - -def test_configured_toolsets_supply_the_badge_tooltip_and_icon() -> None: - """A project's own vocabulary reaches the rendered badge.""" - from sphinx_autodoc_fastmcp._badges import use_toolsets - from sphinx_autodoc_fastmcp._models import coerce_toolsets - - use_toolsets(coerce_toolsets(({"tag": "execute", "tooltip": "Runs a command"},))) + assert resolve_axes(axes, tags={"readonly", "lifecycle"}) == { + "risk": "readonly", + "topic": "lifecycle", + } + assert resolve_axes(axes, tags={"mutating", "lifecycle"}) == { + "risk": "mutating", + "topic": "lifecycle", + } + + +def test_an_axis_can_read_its_term_from_mcp_hints() -> None: + """MCP's own annotations classify a tool without any project config.""" + from sphinx_autodoc_fastmcp._models import DEFAULT_AXES, resolve_axes + + assert resolve_axes(DEFAULT_AXES, annotations={"readOnlyHint": True}) == { + "risk": "readonly" + } + assert resolve_axes( + DEFAULT_AXES, annotations={"readOnlyHint": False, "destructiveHint": True} + ) == {"risk": "destructive"} + assert resolve_axes(DEFAULT_AXES, annotations={}) == {} + + +def test_an_axis_can_read_its_term_from_tool_meta() -> None: + """``meta`` is MCP's own extension point, so an axis can key off it.""" + from sphinx_autodoc_fastmcp._models import coerce_axes, resolve_axes + + axes = coerce_axes(({"name": "tier", "source": "meta:tier"},)) + + assert resolve_axes(axes, meta={"tier": "gold"}) == {"tier": "gold"} + assert resolve_axes(axes, meta={}) == {} + + +def test_a_declared_term_supplies_the_badge_tooltip_icon_and_tone() -> None: + """Presentation is per term, so a project restyles without touching CSS.""" + from sphinx_autodoc_fastmcp._badges import build_axis_badge, use_axes + from sphinx_autodoc_fastmcp._models import coerce_axes + + use_axes( + coerce_axes( + ( + { + "name": "risk", + "terms": ( + { + "term": "teardown", + "tooltip": "Runs a command", + "icon": "X", + "tone": "red", + }, + ), + }, + ) + ) + ) try: - badge = build_toolset_badge("execute") + badge = build_axis_badge("risk", "teardown") assert badge["badge_tooltip"] == "Runs a command" + assert "gp-sphinx-fastmcp__toolset--tone-red" in badge["classes"] + # An undeclared term stays visible and claims nothing. + mystery = build_axis_badge("risk", "mystery") + assert mystery["badge_tooltip"] == "Risk: mystery" + assert "gp-sphinx-fastmcp__toolset--tone-slate" in mystery["classes"] finally: - use_toolsets(None) - - assert build_toolset_badge("execute")["badge_tooltip"] == "Toolset: execute" - + use_axes(None) -def test_a_toolset_badge_carries_its_declared_tone() -> None: - """Colour comes from the project's declaration, not a guessed tag name. - The stylesheet cannot ship a rule per tag, because it does not know - what a project calls its toolsets. It ships tones instead, and the - project maps onto them. - """ - from sphinx_autodoc_fastmcp._badges import build_toolset_badge, use_toolsets - from sphinx_autodoc_fastmcp._models import coerce_toolsets - - use_toolsets(coerce_toolsets(({"tag": "teardown", "tone": "red"},))) - try: - classes = build_toolset_badge("teardown")["classes"] - assert "gp-sphinx-fastmcp__toolset--tone-red" in classes - # An undeclared toolset still gets a visible badge, claiming nothing. - assert ( - "gp-sphinx-fastmcp__toolset--tone-slate" - in build_toolset_badge("mystery")["classes"] - ) - finally: - use_toolsets(None) - - -def test_a_toolset_entry_without_a_tag_is_skipped( - caplog: pytest.LogCaptureFixture, -) -> None: +def test_a_term_without_a_name_is_skipped(caplog: pytest.LogCaptureFixture) -> None: """One typo in conf.py costs a badge, not the build.""" - from sphinx_autodoc_fastmcp._models import coerce_toolsets + from sphinx_autodoc_fastmcp._models import coerce_axes with caplog.at_level(logging.WARNING, logger="sphinx_autodoc_fastmcp._models"): - toolsets = coerce_toolsets(({"name": "execute"}, {"tag": "inspect"})) + axes = coerce_axes( + ({"name": "risk", "terms": ({"label": "oops"}, {"term": "inspect"})},) + ) - assert [entry.tag for entry in toolsets] == ["inspect"] - assert "has no 'tag'" in caplog.text + assert [t.term for t in axes[0].terms] == ["inspect"] + assert "has no 'term'" in caplog.text -def test_an_unknown_tone_falls_back_to_slate( - caplog: pytest.LogCaptureFixture, -) -> None: - """The stylesheet has no rule for it, so the badge would render uncoloured.""" - from sphinx_autodoc_fastmcp._models import coerce_toolsets +def test_an_axis_without_a_name_is_skipped(caplog: pytest.LogCaptureFixture) -> None: + """An axis with no name has no CSS class and no way to be referenced.""" + from sphinx_autodoc_fastmcp._models import coerce_axes with caplog.at_level(logging.WARNING, logger="sphinx_autodoc_fastmcp._models"): - (entry,) = coerce_toolsets(({"tag": "teardown", "tone": "grey"},)) + axes = coerce_axes(({"terms": ("a",)}, {"name": "risk"})) - assert entry.tone == "slate" - assert "unknown tone" in caplog.text + assert [a.name for a in axes] == ["risk"] + assert "has no 'name'" in caplog.text diff --git a/tests/ext/fastmcp/test_fastmcp_integration.py b/tests/ext/fastmcp/test_fastmcp_integration.py index 65d52071..68e0f38c 100644 --- a/tests/ext/fastmcp/test_fastmcp_integration.py +++ b/tests/ext/fastmcp/test_fastmcp_integration.py @@ -62,7 +62,12 @@ def list_sessions(server: str, limit: int = 20) -> str: fastmcp_tool_modules = ["demo_tools"] fastmcp_area_map = {"demo_tools": "api"} - fastmcp_toolsets = ("destructive", "mutating", "readonly") + fastmcp_axes = ( + { + "name": "risk", + "terms": ("destructive", "mutating", "readonly"), + }, + ) fastmcp_collector_mode = "introspect" """ ) @@ -171,7 +176,12 @@ def delete_buffer(name: str) -> str: fastmcp_tool_modules = ["buffer_tools"] fastmcp_area_map = {"buffer_tools": "api"} - fastmcp_toolsets = ("destructive", "mutating", "readonly") + fastmcp_axes = ( + { + "name": "risk", + "terms": ("destructive", "mutating", "readonly"), + }, + ) fastmcp_collector_mode = "introspect" """ ) @@ -332,7 +342,7 @@ def test_heading_collision_anchor_counts( fastmcp_tool_modules = ["demo_tools"] fastmcp_area_map = {"demo_tools": "api"} - fastmcp_toolsets = ("destructive", "mutating") + fastmcp_axes = ({"name": "risk", "terms": ("destructive", "mutating")},) fastmcp_collector_mode = "introspect" """ ) @@ -403,17 +413,17 @@ def test_the_summary_warns_when_it_drops_an_unmatched_tool( @pytest.mark.integration -def test_summary_sections_anchor_on_the_toolset_tag( +def test_summary_sections_anchor_on_the_axis_term( fastmcp_heading_collision_result: SharedSphinxResult, ) -> None: """A tag keeps one anchor whatever its rendered heading reads.""" html = read_output(fastmcp_heading_collision_result, "index.html") - assert 'id="fastmcp-toolset-destructive"' in html + assert 'id="fastmcp-risk-destructive"' in html @pytest.mark.integration -def test_the_vocabulary_survives_a_second_build_of_one_app( +def test_the_axes_survive_a_second_build_of_one_app( tmp_path: pathlib.Path, ) -> None: """Nothing may clear the vocabulary per build. @@ -424,14 +434,14 @@ def test_the_vocabulary_survives_a_second_build_of_one_app( """ from sphinx.application import Sphinx - from sphinx_autodoc_fastmcp._badges import build_toolset_badge, use_toolsets + from sphinx_autodoc_fastmcp._badges import build_axis_badge, use_axes src = tmp_path / "src" src.mkdir() (src / "conf.py").write_text( 'extensions = ["sphinx_autodoc_fastmcp"]\n' - 'fastmcp_toolsets = ({"tag": "execute", "tooltip": "Runs it",' - ' "tone": "red"},)\n', + 'fastmcp_axes = ({"name": "risk", "terms": ({"term": "execute",' + ' "tooltip": "Runs it", "tone": "red"},)},)\n', ) (src / "index.rst").write_text("Tools\n=====\n") @@ -448,8 +458,8 @@ def test_the_vocabulary_survives_a_second_build_of_one_app( app.build() try: - badge = build_toolset_badge("execute") + badge = build_axis_badge("risk", "execute") assert badge["badge_tooltip"] == "Runs it" assert "gp-sphinx-fastmcp__toolset--tone-red" in badge["classes"] finally: - use_toolsets(None) + use_axes(None) diff --git a/tests/ext/fastmcp/test_prototype.py b/tests/ext/fastmcp/test_prototype.py index 8857dddd..567099da 100644 --- a/tests/ext/fastmcp/test_prototype.py +++ b/tests/ext/fastmcp/test_prototype.py @@ -20,7 +20,8 @@ def _make_tool_info() -> ToolInfo: title="List Sessions", module_name="demo_tools", area="api", - toolset="readonly", + axes={"risk": "readonly"}, + meta={}, annotations={}, func=lambda server: "[]", docstring="List sessions for one server.\n\nReturns the available sessions.", diff --git a/tests/ext/layout/__snapshots__/test_snapshots.ambr b/tests/ext/layout/__snapshots__/test_snapshots.ambr index 15bfeec1..792c7ddb 100644 --- a/tests/ext/layout/__snapshots__/test_snapshots.ambr +++ b/tests/ext/layout/__snapshots__/test_snapshots.ambr @@ -275,7 +275,7 @@ - + readonly @@ -284,7 +284,7 @@ - + readonly diff --git a/tests/ext/layout/test_snapshots.py b/tests/ext/layout/test_snapshots.py index 32cdc3fd..d1ed530f 100644 --- a/tests/ext/layout/test_snapshots.py +++ b/tests/ext/layout/test_snapshots.py @@ -204,7 +204,8 @@ def _make_fastmcp_tool_desc() -> addnodes.desc: title="List Sessions", module_name="demo_tools", area="api", - toolset="readonly", + axes={"risk": "readonly"}, + meta={}, annotations={}, func=lambda server: "[]", docstring=( @@ -301,8 +302,19 @@ def test_rst_directive_snapshot(snapshot_doctree: t.Callable[..., None]) -> None def test_fastmcp_tool_prototype_snapshot( snapshot_doctree: t.Callable[..., None], ) -> None: - """FastMCP prototype entries snapshot the shared desc layout contract.""" - snapshot_doctree( - _rendered_managed_desc(_make_fastmcp_tool_desc(), show_annotations=True), - name="fastmcp_tool_prototype", - ) + """FastMCP prototype entries snapshot the shared desc layout contract. + + Pins the axes it renders under: they are process-global, so a Sphinx + build earlier in the session would otherwise pick the badge text. + """ + from sphinx_autodoc_fastmcp._badges import use_axes + from sphinx_autodoc_fastmcp._models import coerce_axes + + use_axes(coerce_axes(({"name": "risk", "terms": ("readonly",)},))) + try: + snapshot_doctree( + _rendered_managed_desc(_make_fastmcp_tool_desc(), show_annotations=True), + name="fastmcp_tool_prototype", + ) + finally: + use_axes(None) diff --git a/tests/test_docs_package_pages.py b/tests/test_docs_package_pages.py index daebec76..8189bf2a 100644 --- a/tests/test_docs_package_pages.py +++ b/tests/test_docs_package_pages.py @@ -108,7 +108,9 @@ def _fastmcp_docs_page() -> str: master_doc = "api" fastmcp_tool_modules = ["fastmcp_demo_tools"] fastmcp_area_map = {{"fastmcp_demo_tools": "api"}} - fastmcp_toolsets = ("teardown", "execute", "inspect") + fastmcp_axes = ( + {{"name": "capability", "terms": ("teardown", "execute", "inspect")}}, + ) fastmcp_collector_mode = "introspect" """ ) From c330dbe565bfe40292760e6ef909731a4445473a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 07:26:55 -0500 Subject: [PATCH 17/23] fastmcp(test[axes]): Cover three sources classifying one tool The headline capability had no end-to-end coverage: that one tool is classified on several axes at once, and that each source reads what it claims to. Builds a tool tagged ``mutating``/``lifecycle``, annotated ``readOnlyHint=False`` with ``destructiveHint=False``, and carrying ``meta={"since": "1.2"}``, against three axes reading annotations, tags and meta. Asserts all three badges render, including that the hint pair resolves to ``mutating`` per the MCP spec rather than to ``destructive``. Fails against a renderer that emits one badge per tool. --- tests/ext/fastmcp/test_fastmcp_integration.py | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/ext/fastmcp/test_fastmcp_integration.py b/tests/ext/fastmcp/test_fastmcp_integration.py index 68e0f38c..243d128f 100644 --- a/tests/ext/fastmcp/test_fastmcp_integration.py +++ b/tests/ext/fastmcp/test_fastmcp_integration.py @@ -463,3 +463,81 @@ def test_the_axes_survive_a_second_build_of_one_app( assert "gp-sphinx-fastmcp__toolset--tone-red" in badge["classes"] finally: use_axes(None) + + +_TWO_AXIS_MODULE = textwrap.dedent( + """\ + from __future__ import annotations + + import types + + + def start_run(script: str) -> str: + \"\"\"Start a load test.\"\"\" + + return "" + + + start_run.__fastmcp__ = types.SimpleNamespace( + name="start_run", + title="Start Run", + tags={"mutating", "lifecycle"}, + annotations={"readOnlyHint": False, "destructiveHint": False}, + meta={"since": "1.2"}, + ) + """ +) + +_TWO_AXIS_CONF = textwrap.dedent( + """\ + from __future__ import annotations + + import sys + + sys.path.insert(0, r"__SCENARIO_SRCDIR__") + + extensions = ["sphinx_autodoc_fastmcp"] + fastmcp_tool_modules = ["demo_tools"] + fastmcp_area_map = {"demo_tools": "api"} + fastmcp_collector_mode = "introspect" + fastmcp_axes = ( + {"name": "risk", "source": "annotations"}, + {"name": "topic", "terms": ("lifecycle", "metrics")}, + {"name": "since", "source": "meta:since"}, + ) + """ +) + + +@pytest.mark.integration +def test_a_tool_renders_one_badge_per_declared_axis( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Three sources classify one tool at once, and each gets its own badge. + + A single vocabulary has to pick one of these and drop the rest. + """ + cache_root = tmp_path_factory.mktemp("fastmcp-two-axis") + scenario = SphinxScenario( + files=( + ScenarioFile("demo_tools.py", _TWO_AXIS_MODULE), + ScenarioFile( + "conf.py", + _TWO_AXIS_CONF.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), + substitute_srcdir=True, + ), + ScenarioFile( + "index.rst", + "Tools\n=====\n\n.. fastmcp-tool:: demo_tools.start_run\n", + ), + ), + ) + html = read_output( + build_shared_sphinx_result(cache_root, scenario, purge_modules=("demo_tools",)), + "index.html", + ) + + # readOnlyHint False + destructiveHint False is "mutating" per the MCP spec. + assert "gp-sphinx-fastmcp__risk-mutating" in html + assert "gp-sphinx-fastmcp__topic-lifecycle" in html + assert "gp-sphinx-fastmcp__since-1.2" in html From 65e9c3224bb536a0f75879d7e3eedd6c13de2233 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 07:27:19 -0500 Subject: [PATCH 18/23] docs(fastmcp): Show how to declare axes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The how-to described one vocabulary and a fixed tone list, neither of which the extension has any more. Rewritten around what a project actually does: one axis from tags, two axes when the tags carry two ideas, an axis from MCP's annotations or from ``meta``, and a table of what each source reads. Adds the two CSS entry points — redefining a shipped tone's palette variables, and defining a class for a tone the extension does not ship — plus the axis argument to ``fastmcp-tool-summary``. --- .../packages/sphinx-autodoc-fastmcp/how-to.md | 133 ++++++++++++++---- 1 file changed, 107 insertions(+), 26 deletions(-) diff --git a/docs/packages/sphinx-autodoc-fastmcp/how-to.md b/docs/packages/sphinx-autodoc-fastmcp/how-to.md index 3bdc1301..75ccf1db 100644 --- a/docs/packages/sphinx-autodoc-fastmcp/how-to.md +++ b/docs/packages/sphinx-autodoc-fastmcp/how-to.md @@ -25,43 +25,124 @@ fastmcp_collector_mode = "register" fastmcp_server_module = "my_project.server:mcp" ``` -## Declare your toolsets +## Classify your tools -Tools are badged from their tags, and this extension ships **no** default -vocabulary — it renders documentation for projects whose tags it does not -choose, so a default would badge one project's tools with another's words. -Declare `fastmcp_toolsets`: +A tool is classified on one or more **axes**. Each axis is independent, so a +tool takes at most one term per axis and renders one badge per axis. That is +the difference from a single vocabulary: risk and topic can disagree without +one having to win. + +This extension ships no project vocabulary. It documents projects whose tags it +does not choose, so a default would badge one project's tools with another's +words. + +### One axis from your tags ```python -fastmcp_toolsets = ( +fastmcp_axes = ( { - "tag": "teardown", - "tooltip": "Deletes objects; not reversible.", - "icon": "\N{BOMB}", + "name": "capability", + "terms": ( + { + "term": "teardown", + "tooltip": "Deletes objects; not reversible.", + "icon": "\N{BOMB}", + "tone": "red", + }, + {"term": "execute", "tooltip": "Starts or drives a process."}, + "manage", + "inspect", + ), }, - {"tag": "execute", "tooltip": "Starts or drives a process."}, - "manage", - "inspect", ) ``` -Order is precedence: a tool carrying several of these tags is badged with the -first one listed. An entry may be a bare tag name or a mapping with a `tooltip` -and an `icon`. The `{fastmcp-summary}` directive groups its tables in the same -order, titling each section from the tag. +`terms` order is precedence: a tool carrying several of them takes the first +listed. A term is a bare tag name or a mapping with `label`, `tooltip`, `icon`, +`tone`, `style`, `fill` and `classes`. + +A tool matching no term renders **no** badge for that axis. Falling back to a +term nobody assigned is the one answer a badge must never give. + +### Two axes at once + +Tags often carry two ideas. Declare an axis for each and both badges render: + +```python +fastmcp_axes = ( + {"name": "risk", "terms": ("mutating", "readonly")}, + {"name": "topic", "terms": ("lifecycle", "metrics", "thresholds")}, +) +``` + +A read-only `lifecycle` tool now shows `readonly` *and* `lifecycle`, where a +single vocabulary would have to drop one. + +### Axes from MCP's own metadata + +`source` says where a term comes from. It defaults to `tags`: + +| `source` | Reads | +| --- | --- | +| `tags` | the tool's `tags`, matched against the declared terms | +| `annotations` | `ToolAnnotations`, yielding `readonly`, `mutating` or `destructive` | +| `meta:` | `meta[]`, whatever the tool put there | + +```python +fastmcp_axes = ( + {"name": "risk", "source": "annotations"}, + {"name": "since", "source": "meta:since"}, +) +``` -A tool carrying none of the tags renders **without** a toolset badge. Falling -back to a tag nobody assigned is the one answer a badge must never give, and -with no declared vocabulary that is every tool — which is the signal that the -setting is missing. +The `annotations` source follows the MCP spec: `destructiveHint` describes a +tool only once `readOnlyHint` is false, and an unset hint says nothing rather +than defaulting. A tool that sets no hints takes no term, so declaring this +axis costs nothing until your tools carry annotations. -`tone` picks the badge colour from `green`, `blue`, `amber`, `red` and -`slate`, defaulting to `slate`. The stylesheet ships tones rather than a rule -per tag, because it cannot know what a project calls its toolsets — so a rule -per tag would only ever style the names it happened to guess. +### Colours, and adding your own -Each toolset also gets the class `gp-sphinx-fastmcp__toolset-`, for a -project that wants to style one of its own tags beyond the shipped tones. +`tone` names a colour: `green`, `blue`, `amber`, `red` or `slate`, defaulting +to `slate`. Tones are three CSS layers, so you can enter at whichever you need. + +Restyle a shipped tone by redefining its palette variables: + +```css +:root { + --gp-sphinx-fastmcp-tone-red-bg: #7f1d1d; + --gp-sphinx-fastmcp-tone-red-border: #991b1b; + --gp-sphinx-fastmcp-tone-red-text: #fef2f2; +} +``` + +Add a tone the extension does not ship by defining its class, then naming it: + +```css +.gp-sphinx-fastmcp__toolset--tone-teal { + --gp-sphinx-fastmcp-badge-bg: #0f766e; + --gp-sphinx-fastmcp-badge-border: #14b8a6; + --gp-sphinx-fastmcp-badge-text: #f0fdfa; +} +``` + +```python +{"term": "audit", "tone": "teal"} +``` + +Every badge also carries `gp-sphinx-fastmcp__axis-` and +`gp-sphinx-fastmcp__-`, so you can style one axis or one term +directly without going through tones at all. + +### Summary tables + +`{fastmcp-tool-summary}` groups by one axis, defaulting to the first declared. +Name another to group by it instead: + +````myst +```{eval-rst} +.. fastmcp-tool-summary:: topic +``` +```` `sphinx_autodoc_fastmcp` automatically registers `sphinx_ux_badges`, `sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via From f7eeaa12017e5438e57a041541dc5815bc91ced2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 07:27:24 -0500 Subject: [PATCH 19/23] docs(fastmcp): Move the demo onto a declared axis The demo site's three toolsets become one ``capability`` axis, keeping the tones, tooltips and icons the previous commit gave them. Names the axis rather than leaving it implicit, so the rendered classes on the examples page read ``__axis-capability`` and ``__capability-inspect`` and show what a project's own axis produces. --- docs/conf.py | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index d8a31492..f8e54765 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -110,24 +110,29 @@ fastmcp_area_map={ "fastmcp_demo_tools": "packages/sphinx-autodoc-fastmcp/examples", }, - fastmcp_toolsets=( + fastmcp_axes=( { - "tag": "teardown", - "tone": "red", - "tooltip": "Removes objects; not reversible.", - "icon": "\N{BOMB}", - }, - { - "tag": "execute", - "tone": "amber", - "tooltip": "Starts or drives a process.", - "icon": "\N{PENCIL}\N{VARIATION SELECTOR-16}", - }, - { - "tag": "inspect", - "tone": "green", - "tooltip": "Reads state without changing it.", - "icon": "\N{LEFT-POINTING MAGNIFYING GLASS}", + "name": "capability", + "terms": ( + { + "term": "teardown", + "tone": "red", + "tooltip": "Removes objects; not reversible.", + "icon": "\N{BOMB}", + }, + { + "term": "execute", + "tone": "amber", + "tooltip": "Starts or drives a process.", + "icon": "\N{PENCIL}\N{VARIATION SELECTOR-16}", + }, + { + "term": "inspect", + "tone": "green", + "tooltip": "Reads state without changing it.", + "icon": "\N{LEFT-POINTING MAGNIFYING GLASS}", + }, + ), }, ), fastmcp_collector_mode="introspect", From 74d67f498e125c9879b03597d772a599dbb45e98 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 07:32:48 -0500 Subject: [PATCH 20/23] ci(docs): Invalidate every path after a full sync The S3 sync replaces the whole site with --delete, but the invalidation named only /index.html, /objects.inv and /searchindex.js. Every other page kept serving CloudFront's cached copy until its TTL expired, so a deploy landed in the bucket and stayed invisible for the better part of an hour. A wildcard covers the pages the sync actually replaced, and bills as one path rather than one per page. --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0e730b56..198af159 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -108,7 +108,7 @@ jobs: run: | aws cloudfront create-invalidation \ --distribution-id "${{ secrets.GP_SPHINX_DOCS_DISTRIBUTION }}" \ - --paths "/index.html" "/objects.inv" "/searchindex.js" + --paths "/*" - name: Purge cache on Cloudflare if: steps.changes.outputs.publishable == 'true' From 7efa4d67681267c7ab801268f6a45a9c18d460a7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 07:37:04 -0500 Subject: [PATCH 21/23] docs(CHANGES) Record the axis taxonomy and the badge fixes Covers the branch's net change in one entry rather than the five successive ones the branch accumulated while the API was still moving: the move from a single vocabulary to named axes, what breaks with it, the annotations and meta sources, per-term presentation, extensible tones, and the badge and summary fixes found along the way. --- CHANGES | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/CHANGES b/CHANGES index 35b105f0..dbdafdb4 100644 --- a/CHANGES +++ b/CHANGES @@ -18,6 +18,111 @@ $ uv add gp-sphinx --prerelease allow +### Breaking changes + +#### Tools are classified on axes, not on one vocabulary + +`fastmcp_toolsets` is replaced by `fastmcp_axes`. An axis is one +independent way of classifying a tool; a tool takes at most one term per +axis and renders one badge per axis, in declaration order. + +```python +# Before +fastmcp_toolsets = ("destructive", "mutating", "readonly") + +# After +fastmcp_axes = ( + {"name": "risk", "terms": ("destructive", "mutating", "readonly")}, +) +``` + +`ToolInfo.safety` is now `ToolInfo.axes`, a mapping of axis name to term, +and `ToolInfo.meta` carries the tool's `meta`. `fastmcp_section_badge_map` +values accept `term` or `axis:term`. `fastmcp-tool-summary` anchors its +sections on `#fastmcp--` rather than on the rendered heading, +so links into the old anchors need updating. (#77) + +#### No vocabulary is assumed + +Tags previously resolved against a fixed `destructive` / `mutating` / +`readonly` set, ending in a fallback to `readonly`. A project using +different tags had every tool badged read-only, including the ones that +run commands. + +Nothing is assumed now: a project declares its own axes, and a tool +matching no term on an axis renders no badge for it. Until +`fastmcp_axes` is set, tools carry no badges — which is the signal that +the setting is missing. (#77) + +### What's new + +#### One tool, several axes + +Tags usually carry more than one idea. A read-only lifecycle tool used to +be reported as its risk or as its topic, never both, because a tool held +a single term. + +Declare an axis for each and both badges render: + +```python +fastmcp_axes = ( + {"name": "risk", "terms": ("mutating", "readonly")}, + {"name": "topic", "terms": ("lifecycle", "metrics")}, +) +``` + +See {ref}`sphinx-autodoc-fastmcp-how-to` for the full shape. (#77) + +#### Axes read MCP's own metadata + +An axis names where its term comes from. `tags` matches declared terms +against the tool's tags. `annotations` reads `ToolAnnotations`, following +the MCP spec so `destructiveHint` describes a tool only once +`readOnlyHint` is false, and an unset hint says nothing rather than +defaulting. `meta:` reads the mapping MCP passes through to clients. + +A tool that sets no hints takes no term, so declaring an `annotations` +axis costs nothing until the tools carry them. (#77) + +#### Badge presentation is per term + +A term carries its own `label`, `tooltip`, `icon`, `tone`, `style`, +`fill` and `classes`. Badges render `gp-sphinx-fastmcp__axis-` and +`gp-sphinx-fastmcp__-`, so a project can style one axis or +one term directly. (#77) + +#### Tones extend without touching the extension + +Tones resolve through three CSS layers: `:root` names the palette, a +`--tone-` class maps one entry onto the badge slot, and one rule +consumes the slot. Restyling a shipped tone means redefining three +variables; adding a tone the extension does not ship means writing one +class and naming it from `conf.py`. (#77) + +### Fixes + +- A tool matching no declared term rendered an empty badge with a bare + `Toolset: ` tooltip from `{tool}` and `{toolref}`; it now renders as + plain text. (#77) +- `fastmcp-tool-summary` dropped tools it could not place, and rendered + nothing at all when no vocabulary was declared, both without a + diagnostic. It now warns and names the tools it left out. (#77) +- A `fastmcp_axes` entry with no name, or a term with no name, aborted + the build with a bare `KeyError`. Both now warn and are skipped. (#77) +- A tone the stylesheet has no rule for rendered an uncoloured badge with + nothing to point at the typo; it now warns and falls back. (#77) +- The `fastmcp_toolsets` config description claimed an empty value kept + the old three tags, contradicting itself two sentences later. (#77) + +### Documentation + +#### Classifying tools has a how-to + +The rules a project follows — one axis from tags, two axes when the tags +carry two ideas, an axis from annotations or `meta`, and the two CSS +entry points for tones — are written down. See +{ref}`sphinx-autodoc-fastmcp-how-to`. (#77) + ## gp-sphinx 0.1.0a37 (2026-07-26) gp-sphinx 0.1.0a37 gives every class-level name — a field, a From 0de166203ebf87bf7331f2baec965066a1788187 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 08:19:30 -0500 Subject: [PATCH 22/23] badges(fix[css]): Keep an icon-only link inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The icon-only link was `display: inline-flex`, which blockifies its children. The `` chip lost its inline metrics and rendered 25.4px tall against the 21.6px of the plain code beside it on the same line, hanging 3.4px lower. Measured on libtmux-mcp's trust page: `set_option` sat 3.8px taller and 3.4px lower than `manage` and `#(...)` next to it. Keeping the link inline lets the chip size itself, matching its neighbours exactly. `margin-inline` on the badge replaces `gap`, which needs a flex container, and `white-space: nowrap` keeps the icon with its name — the one thing the flex box gave for free, by being an atomic inline box that could not split across a line break. Aligning to the baseline or zeroing the chip's padding narrows the gap but leaves it 2.5-3.8px too tall, since neither stops the blockification that causes it. --- .../_static/css/sphinx_ux_badges.css | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sphinx_ux_badges.css b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sphinx_ux_badges.css index 594118f9..30299222 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sphinx_ux_badges.css +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sphinx_ux_badges.css @@ -211,11 +211,23 @@ body[data-theme="dark"] .gp-sphinx-badge:not(.gp-sphinx-badge--outline):not(.gp- opacity: 0.9; } -/* Icon-only links: flexbox parent for consistent spacing */ +/* Icon-only links stay inline: a flex parent blockifies the code chip, + * which drops its inline metrics and leaves it taller than the plain + * code around it. Margin on the badge spaces them instead of `gap`, + * and nowrap keeps the icon with its name, which the flex box used to + * guarantee by being unsplittable. */ a.reference:has(> .gp-sphinx-badge.gp-sphinx-badge--icon-only) { - display: inline-flex; - align-items: center; - gap: 3px; + white-space: nowrap; +} + +a.reference:has(> .gp-sphinx-badge.gp-sphinx-badge--icon-only) + > .gp-sphinx-badge--icon-only:first-child { + margin-inline-end: 3px; +} + +a.reference:has(> .gp-sphinx-badge.gp-sphinx-badge--icon-only) + > .gp-sphinx-badge--icon-only:last-child { + margin-inline-start: 3px; } a.reference:has(> .gp-sphinx-badge.gp-sphinx-badge--icon-only) > code { From 44975a224d43a958cf308ac30b73465b878ff51e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 08:24:42 -0500 Subject: [PATCH 23/23] docs(CHANGES) Note the icon-only link alignment fix --- CHANGES | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES b/CHANGES index dbdafdb4..742ec7db 100644 --- a/CHANGES +++ b/CHANGES @@ -113,6 +113,9 @@ class and naming it from `conf.py`. (#77) nothing to point at the typo; it now warns and falls back. (#77) - The `fastmcp_toolsets` config description claimed an empty value kept the old three tags, contradicting itself two sentences later. (#77) +- An icon-only tool link rendered its code chip 3.8px taller than the + plain code beside it, hanging below the line. The link no longer + builds a flex context, so the chip keeps its inline metrics. (#77) ### Documentation