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' diff --git a/CHANGES b/CHANGES index 35b105f0..742ec7db 100644 --- a/CHANGES +++ b/CHANGES @@ -18,6 +18,114 @@ $ 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) +- 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 + +#### 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 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..f8e54765 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -110,6 +110,31 @@ fastmcp_area_map={ "fastmcp_demo_tools": "packages/sphinx-autodoc-fastmcp/examples", }, + fastmcp_axes=( + { + "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", 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 23ea9579..75ccf1db 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,6 +25,125 @@ fastmcp_collector_mode = "register" fastmcp_server_module = "my_project.server:mcp" ``` +## Classify your tools + +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_axes = ( + { + "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", + ), + }, +) +``` + +`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"}, +) +``` + +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. + +### Colours, and adding your own + +`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 {py:meth}`~sphinx.application.Sphinx.setup_extension`. You do not need to add 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/__init__.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py index 62b999bf..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,6 +15,7 @@ from sphinx.application import Sphinx +from sphinx_autodoc_fastmcp._badges import use_axes 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_axes from sphinx_autodoc_fastmcp._roles import ( _prompt_role, _promptref_role, @@ -129,8 +131,8 @@ 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. " - '``"readonly"``, ``"mutating"``, ``"destructive"``). ' + '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." ), ) @@ -144,6 +146,21 @@ def setup(app: Sphinx) -> dict[str, t.Any]: "section headings." ), ) + app.add_config_value( + "fastmcp_axes", + (), + "env", + description=( + "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( "fastmcp_collector_mode", "register", @@ -173,6 +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_axes(app: Sphinx) -> None: + use_axes(coerce_axes(app.config.fastmcp_axes)) + + 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 995af20d..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,6 +7,7 @@ from docutils import nodes from sphinx_autodoc_fastmcp._css import _CSS +from sphinx_autodoc_fastmcp._models import DEFAULT_AXES, Axis from sphinx_ux_badges import ( SAB, BadgeNode, @@ -16,74 +17,95 @@ build_toolbar as _sab_build_toolbar, ) -_SAFETY_LABELS = ("readonly", "mutating", "destructive") +#: 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 -_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_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 + + +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) + _TYPE_TOOLTIP = "MCP tool" -def build_safety_badge( - safety: str, +def term_spec(axis_name: str, value: str) -> BadgeSpec: + """Return the badge spec for ``value`` on axis ``axis_name``. + + 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( + 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.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"), + ) + + +def build_axis_badge( + axis_name: str, + value: str, *, icon_only: bool = False, ) -> BadgeNode: - """Build a safety tier badge. - - Parameters - ---------- - safety : str - One of ``readonly``, ``mutating``, ``destructive``. - icon_only : bool - When True, create an icon-only badge (empty text, 16x16 colored box). - - Returns - ------- - BadgeNode + """Build one axis badge. Examples -------- - >>> b = build_safety_badge("readonly") - >>> b.astext() + >>> build_axis_badge("risk", "readonly").astext() 'readonly' """ - label = safety if safety in _SAFETY_LABELS else safety - text = "" if icon_only else label + 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 ) - 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 spec.text, + tooltip=spec.tooltip, + icon=spec.icon, + classes=list(spec.classes), style=style, + fill=spec.fill, ) 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 -------- - >>> b = build_type_tool_badge() - >>> b.astext() + >>> build_type_tool_badge().astext() 'tool' """ return build_badge( @@ -93,56 +115,66 @@ def build_type_tool_badge() -> BadgeNode: ) -def build_tool_badge_group(safety: str) -> nodes.inline: - """Badge group: safety tier + type ``tool``. +def primary_axis(axes: dict[str, str]) -> tuple[str, str] | None: + """Return the ``(axis, term)`` a single inline badge should show. - Parameters - ---------- - safety : str - Safety tier name. + Inline references have room for one badge, so they take the first + declared axis the tool matched. - Returns - ------- - nodes.inline + 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) + + +def build_tool_badge_group(axes: dict[str, str]) -> nodes.inline: + """Badge group: one badge per matched axis, then the type badge. + + 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 """ - 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] = [ + 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", + 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: +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(safety)) + 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 dfd76845..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,21 +11,21 @@ from sphinx.application import Sphinx from sphinx_autodoc_fastmcp._models import ( + DEFAULT_AXES, + Axis, PromptArgInfo, PromptInfo, ResourceInfo, ResourceTemplateInfo, ToolInfo, + coerce_axes, + resolve_axes, ) 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,28 +34,29 @@ def __init__( self, *, area_map: dict[str, str], + axes: tuple[Axis, ...] = DEFAULT_AXES, ) -> None: self.tools: list[ToolInfo] = [] self._current_module: str = "" self._area_map = area_map + 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]: - if TAG_DESTRUCTIVE in tags: - safety = "destructive" - elif TAG_MUTATING in tags: - safety = "mutating" - else: - safety = "readonly" + axes = resolve_axes( + self.axes, tags=tags, annotations=annotations, meta=meta + ) module_name = self._current_module area = self._area_map.get( @@ -69,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, - safety=safety, + axes=axes, annotations=annotations, + meta=meta, func=func, docstring=func.__doc__ or "", params=extract_params(func), @@ -84,47 +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], + 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() - if TAG_DESTRUCTIVE in tags: - safety = "destructive" - elif TAG_MUTATING in tags: - safety = "mutating" - else: - safety = "readonly" + 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, - safety=safety, + axes=resolved, annotations=ann_dict, + meta=meta, func=func, docstring=func.__doc__ or "", params=extract_params(func), @@ -138,6 +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) + axes = coerce_axes(app.config.fastmcp_axes) mode = str(app.config.fastmcp_collector_mode) if mode not in ("register", "introspect"): logger.warning( @@ -156,7 +175,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, axes=axes) for dotted in modules: mod_suffix = dotted.split(".")[-1] collector._current_module = mod_suffix @@ -190,6 +209,7 @@ def collect_tools(app: Sphinx) -> None: obj, module_name=mod_suffix, area_map=area_map, + 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 8aed7a7b..a2f9ad8d 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,38 @@ 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 + BADGE_TOOLSET = "gp-sphinx-fastmcp__toolset" @staticmethod - def safety_class(safety: str) -> str: - """Return safety modifier class for badge styling. + def tone_class(tone: str) -> str: + """Return the badge colour class for a term's tone. Examples -------- - >>> _CSS.safety_class("readonly") - 'gp-sphinx-fastmcp__safety-readonly' + >>> _CSS.tone_class("red") + 'gp-sphinx-fastmcp__toolset--tone-red' """ - return f"gp-sphinx-fastmcp__safety-{safety}" + return f"gp-sphinx-fastmcp__toolset--tone-{tone}" + + @staticmethod + def axis_class(axis: str) -> str: + """Return the axis modifier class. + + Examples + -------- + >>> _CSS.axis_class("risk") + 'gp-sphinx-fastmcp__axis-risk' + """ + 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 92788a8b..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,6 +13,7 @@ from sphinx.environment import BuildEnvironment from sphinx_autodoc_fastmcp._badges import ( + active_axes, 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.axes), permalink=link, entry_classes=(_CSS.TOOL_ENTRY,), signature_classes=(_CSS.TOOL_SIGNATURE,), @@ -378,14 +379,18 @@ 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 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 tier sections with tables.""" + """Build one section of tables per term on the chosen axis.""" tools: dict[str, ToolInfo] = getattr(self.env, "fastmcp_tools", {}) if not tools: @@ -396,36 +401,55 @@ def run(self) -> list[nodes.Node]: ), ] - groups: dict[str, list[ToolInfo]] = { - "readonly": [], - "mutating": [], - "destructive": [], - } + 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.safety, []).append(tool) + groups.setdefault(tool.axes.get(axis.name, ""), []).append(tool) - result_nodes: list[nodes.Node] = [] + unassigned = groups.get("", []) + if unassigned: + logger.warning( + "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)), + ) - tier_order = [ - ("readonly", "Inspect", "Read state without changing anything."), - ("mutating", "Act", "Create or modify objects."), - ("destructive", "Destroy", "Remove objects; not reversible."), - ] + result_nodes: list[nodes.Node] = [] - for safety, label, desc in tier_order: - tier_tools = groups.get(safety, []) - if not tier_tools: + for term in axis.terms: + group_tools = groups.get(term.term, []) + if not group_tools: continue + label = term.label or term.term.replace("_", " ").title() + desc = term.tooltip section = nodes.section() - section["ids"].append(label.lower()) + 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) 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 56693ff4..6cc12e9f 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,256 @@ from __future__ import annotations +import logging import typing as t from dataclasses import dataclass, field +logger = logging.getLogger(__name__) + +#: 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 Term: + """One value an axis can take, and how it renders. + + Attributes + ---------- + term : str + Value to match, and the badge label unless ``label`` overrides it. + label : str + Badge text. Defaults to ``term``. + tooltip : str + Hover text. Falls back to ``": "``. + icon : str + Emoji rendered before the label. Optional. + tone : str + 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. + """ + + term: str + label: str = "" + tooltip: str = "" + icon: str = "" + tone: str = "slate" + style: str = "full" + fill: str = "filled" + classes: tuple[str, ...] = () + + +@dataclass(frozen=True) +class Axis: + """One independent way of classifying a tool. + + A tool takes at most one term per axis, so two axes render two badges. + + 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. + """ + + 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 + ---------- + value : object + Raw configuration value. + + Returns + ------- + tuple of Axis + Axes in declaration order; badges render in that order. + + Examples + -------- + >>> 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 () + axes: list[Axis] = [] + for entry in value: + if isinstance(entry, Axis): + axes.append(entry) + continue + name = entry.get("name") + if not name: + logger.warning( + "sphinx_autodoc_fastmcp: fastmcp_axes entry %r has no 'name'; " + "skipping it", + entry, + ) + 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 term_from_annotations(hints: dict[str, bool]) -> str: + """Return the risk term MCP's hints imply, or ``""``. + + 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. + annotations : dict of str to bool, optional + MCP hints the tool sets. + meta : dict, optional + The tool's ``meta`` mapping. + + Returns + ------- + dict + Axis name to term, for axes that matched. + + Examples + -------- + >>> axes = coerce_axes(({"name": "topic", "terms": ("admin", "search")},)) + >>> resolve_axes(axes, tags={"search"}) + {'topic': 'search'} + >>> resolve_axes(axes, tags={"other"}) + {} + """ + present = set(tags) + 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 class ParamInfo: @@ -48,12 +295,13 @@ 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"``, - ``"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. @@ -69,8 +317,9 @@ class ToolInfo: title: str module_name: str area: str - safety: 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 b302ac82..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", -... safety="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.safety), + 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 7d7183d0..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 safety badge (only tools have a safety tier). + carries no axis badge (only tools are classified). 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..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 @@ -1,27 +1,34 @@ /* 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). * - * Safety palette: same tokens as sphinx_gp_theme/theme/static/css/custom.css - * so readonly / mutating / destructive match production regardless of load order. + * 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 { - /* ── 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; + /* ── 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; + --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; @@ -49,37 +56,55 @@ --gp-sphinx-fastmcp-mime-border: #d1d5db; } -/* ── 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 { +/* ── 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; } -/* - * Matte safety 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. - */ -.gp-sphinx-badge.gp-sphinx-fastmcp__safety-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; +/* !important: sphinx-design loads after this file and wins the shorthand. */ + +.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__safety-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-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-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-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__safety-destructive:not(.gp-sphinx-badge--inline-icon) { - background-color: #b4232c !important; - color: #fff5f5 !important; - border: 1px solid #cb3640 !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) { @@ -165,34 +190,26 @@ body[data-theme="dark"] { --gp-sphinx-fastmcp-mime-border: #4b5563; } -/* Safety dark-mode box-shadow (safety 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__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--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__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--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__safety-readonly:not([data-icon])::before { - content: "\1F50D"; -} - -.gp-sphinx-fastmcp__safety-mutating:not([data-icon])::before { - content: "\270F\FE0F"; -} - -.gp-sphinx-fastmcp__safety-destructive:not([data-icon])::before { - content: "\1F4A3"; -} - /* ── Tool section card ──────────────────────────────────── */ section.gp-sphinx-fastmcp__tool-section { padding: 0; 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..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_safety_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 safety badges to tier 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,20 +145,20 @@ def add_section_badges( continue title_text = section[0].astext().strip() - safety = badge_map.get(title_text) - if safety is not None: + mapped = badge_map.get(title_text) + if mapped is not None: section[0] += nodes.Text(" ") - section[0] += build_safety_badge(safety) + section[0] += build_axis_badge(*_split_term(mapped)) 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_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: + 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_safety_badge(tool_info.safety, 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: + primary = primary_axis(tool_info.axes) if tool_info else None + if primary: newnode += nodes.Text(" ") - newnode += build_safety_badge(tool_info.safety) + 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 safety-badge branches: - resources and prompts have no safety tier, 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``` → safety badge.""" - return [build_safety_badge(text.strip())], [] + """Role ``:badge:`readonly``` or ``:badge:`risk:readonly``` → axis badge.""" + return [build_axis_badge(*_split_term(text.strip()))], [] 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 { diff --git a/tests/ext/fastmcp/test_fastmcp.py b/tests/ext/fastmcp/test_fastmcp.py index a4bc12cd..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_safety_badge, build_tool_badge_group +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_safety_badge_is_badge_node() -> None: +def test_axis_badge_is_badge_node() -> None: """Safety badge is a BadgeNode (shared package).""" - b = build_safety_badge("mutating") + b = build_axis_badge("risk", "mutating") assert isinstance(b, BadgeNode) assert isinstance(b, nodes.inline) assert b.astext() == "mutating" -def test_safety_badge_has_classes() -> None: - """Safety badge has gp-sphinx-badge + smf safety classes.""" - b = build_safety_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__safety-readonly" in b["classes"] + assert "gp-sphinx-fastmcp__axis-risk" in b["classes"] + assert "gp-sphinx-fastmcp__risk-readonly" in b["classes"] -def test_safety_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) +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() == "" @@ -182,3 +183,139 @@ 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_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 coerce_axes(()) == () + assert resolve_axes((), tags={"anything"}) == {} + + +def test_precedence_follows_declaration_order() -> None: + """A tool carrying several of an axis's terms takes the first declared.""" + from sphinx_autodoc_fastmcp._models import coerce_axes, resolve_axes + + axes = coerce_axes( + ({"name": "cap", "terms": ("teardown", "execute", "manage", "inspect")},) + ) + + assert resolve_axes(axes, tags={"inspect", "teardown"}) == {"cap": "teardown"} + assert resolve_axes(axes, tags={"manage", "inspect"}) == {"cap": "manage"} + + +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_axes, resolve_axes + + axes = coerce_axes(({"name": "cap", "terms": ("inspect", "execute")},)) + + assert resolve_axes(axes, tags={"mystery"}) == {} + assert resolve_axes(axes, tags=set()) == {} + + +def test_two_axes_classify_one_tool_independently() -> None: + """The point of axes: risk and topic can disagree without one winning. + + 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 + + axes = coerce_axes( + ( + {"name": "risk", "terms": ("mutating", "readonly")}, + {"name": "topic", "terms": ("lifecycle", "metrics")}, + ) + ) + + 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_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_axes(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_axes + + with caplog.at_level(logging.WARNING, logger="sphinx_autodoc_fastmcp._models"): + axes = coerce_axes( + ({"name": "risk", "terms": ({"label": "oops"}, {"term": "inspect"})},) + ) + + assert [t.term for t in axes[0].terms] == ["inspect"] + assert "has no 'term'" in caplog.text + + +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"): + axes = coerce_axes(({"terms": ("a",)}, {"name": "risk"})) + + 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 0c52b396..243d128f 100644 --- a/tests/ext/fastmcp/test_fastmcp_integration.py +++ b/tests/ext/fastmcp/test_fastmcp_integration.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging +import pathlib import textwrap import typing as t @@ -60,6 +62,12 @@ def list_sessions(server: str, limit: int = 20) -> str: fastmcp_tool_modules = ["demo_tools"] fastmcp_area_map = {"demo_tools": "api"} + fastmcp_axes = ( + { + "name": "risk", + "terms": ("destructive", "mutating", "readonly"), + }, + ) fastmcp_collector_mode = "introspect" """ ) @@ -168,6 +176,12 @@ def delete_buffer(name: str) -> str: fastmcp_tool_modules = ["buffer_tools"] fastmcp_area_map = {"buffer_tools": "api"} + fastmcp_axes = ( + { + "name": "risk", + "terms": ("destructive", "mutating", "readonly"), + }, + ) fastmcp_collector_mode = "introspect" """ ) @@ -312,3 +326,218 @@ 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_axes = ({"name": "risk", "terms": ("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") + + +@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 + + +@pytest.mark.integration +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-risk-destructive"' in html + + +@pytest.mark.integration +def test_the_axes_survive_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_axis_badge, use_axes + + src = tmp_path / "src" + src.mkdir() + (src / "conf.py").write_text( + 'extensions = ["sphinx_autodoc_fastmcp"]\n' + 'fastmcp_axes = ({"name": "risk", "terms": ({"term": "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_axis_badge("risk", "execute") + assert badge["badge_tooltip"] == "Runs it" + 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 diff --git a/tests/ext/fastmcp/test_prototype.py b/tests/ext/fastmcp/test_prototype.py index 9ad99858..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", - safety="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 2e02e4e9..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 c642f0db..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", - safety="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 92739eab..8189bf2a 100644 --- a/tests/test_docs_package_pages.py +++ b/tests/test_docs_package_pages.py @@ -108,6 +108,9 @@ def _fastmcp_docs_page() -> str: master_doc = "api" fastmcp_tool_modules = ["fastmcp_demo_tools"] fastmcp_area_map = {{"fastmcp_demo_tools": "api"}} + fastmcp_axes = ( + {{"name": "capability", "terms": ("teardown", "execute", "inspect")}}, + ) fastmcp_collector_mode = "introspect" """ ) @@ -232,8 +235,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 # ---------------------------------------------------------------------------