From 6bc24d79e0f5e40717de65a4cdb3771373100501 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 17 Apr 2026 09:32:57 -0500 Subject: [PATCH 01/18] fastmcp-autodoc(feat[prompts,resources]): four new card directives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP prompts and resources now get the same autodoc surface tools do. Four directives, rendered through the shared ``build_api_card_entry`` pipeline: - ``{fastmcp-prompt}`` — name + description card with a ``prompt`` type badge and any tag pills. - ``{fastmcp-prompt-input}`` — arguments table (name / type / required / description), type enriched from the Python signature. - ``{fastmcp-resource}`` — URI + description + ``resource`` badge + MIME pill + facts row. For fixed-URI resources. - ``{fastmcp-resource-template}`` — same shape but with the URI template as the signature and a parameters table derived from the JSON schema (path and query parameters). Collector-wise, a new ``fastmcp_server_module`` config value (``"pkg.server: mcp"``) points at a live FastMCP instance. The collector reads ``local_provider._components`` directly (the async ``_list_*`` helpers are trivial filters over that dict, so we avoid standing up an event loop at Sphinx build time). If the server instance has zero components registered, we attempt the conventional ``register_all()`` / ``_register_all()`` hook on the same module — so docs can enumerate the same surface as a running server even when registration is gated behind ``run_server()``. The collector strips FastMCP's auto-appended ``"Provide as a JSON string matching the following schema: {...}"`` from prompt argument descriptions; it's noise in human docs, useful only to LLM consumers. First-paragraph trimming on the description field keeps the ``Parameters`` and ``Returns`` sections of NumPy-style docstrings out of the signature card — they're rendered separately in the arguments table instead. The directives emit cards inline (no enclosing section), so the surrounding ``##`` markdown heading stays the source of truth for anchors and ToC ordering. That's a different choice than the tool directive made (tools create hidden-title inner sections because there's no outer ``##`` heading above them on the canonical tools pages), but it matches how prompts and resources are typically presented — one readable heading per recipe, with the card slotted beneath. Info dataclasses intentionally don't retain the underlying function. FastMCP resources register closure-local functions via ``@mcp.resource`` inside a ``register()`` closure; those are not picklable into Sphinx's environment cache. We extract ``docstring``, argument types, and everything else eagerly at collect time and store only strings. --- CHANGES | 15 + .../src/sphinx_autodoc_fastmcp/__init__.py | 15 +- .../src/sphinx_autodoc_fastmcp/_badges.py | 102 ++++++ .../src/sphinx_autodoc_fastmcp/_collector.py | 327 +++++++++++++++++- .../src/sphinx_autodoc_fastmcp/_css.py | 11 + .../src/sphinx_autodoc_fastmcp/_directives.py | 296 +++++++++++++++- .../src/sphinx_autodoc_fastmcp/_models.py | 59 +++- 7 files changed, 816 insertions(+), 9 deletions(-) diff --git a/CHANGES b/CHANGES index d783bae0..3ecf510e 100644 --- a/CHANGES +++ b/CHANGES @@ -31,6 +31,21 @@ $ uv add gp-sphinx --prerelease allow ### Features +- `sphinx-autodoc-fastmcp`: autodoc MCP prompts and resources end-to-end. + Four new directives — + ``{fastmcp-prompt}``, ``{fastmcp-prompt-input}``, + ``{fastmcp-resource}``, ``{fastmcp-resource-template}`` — render the + same card-style layout used for tools, with dedicated type badges + (``prompt``, ``resource``, ``resource-template``) plus a MIME pill + next to resources. A new ``fastmcp_server_module`` config value + (``"pkg.server:mcp"``) points the collector at a live + ``FastMCP`` instance; the collector reads + ``local_provider._components`` directly and strips FastMCP's + auto-appended ``"Provide as a JSON string matching the following + schema: {...}"`` hint from prompt argument descriptions so docs + read naturally. If the configured server hasn't run its registration + hook yet, the collector calls ``register_all()`` / ``_register_all()`` + to populate the components. - `sphinx-autodoc-fastmcp`: new Sphinx extension for FastMCP tool docs (card-style `desc` layouts, safety badges, MyST directives, cross-reference roles) - `sphinx-ux-badges`: shared badge node (`BadgeNode`), builder API 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 227fadc1..9ceb750e 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -15,8 +15,15 @@ from sphinx.application import Sphinx -from sphinx_autodoc_fastmcp._collector import collect_tools +from sphinx_autodoc_fastmcp._collector import ( + collect_prompts_and_resources, + collect_tools, +) from sphinx_autodoc_fastmcp._directives import ( + FastMCPPromptDirective, + FastMCPPromptInputDirective, + FastMCPResourceDirective, + FastMCPResourceTemplateDirective, FastMCPToolDirective, FastMCPToolInputDirective, FastMCPToolSummaryDirective, @@ -77,6 +84,7 @@ def setup(app: Sphinx) -> dict[str, t.Any]: app.add_config_value("fastmcp_section_badge_map", {}, "env") app.add_config_value("fastmcp_section_badge_pages", (), "env") app.add_config_value("fastmcp_collector_mode", "register", "env") + app.add_config_value("fastmcp_server_module", "", "env") _static_dir = str(pathlib.Path(__file__).parent / "_static") @@ -88,6 +96,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_css_file("css/sphinx_autodoc_fastmcp.css") app.connect("builder-inited", collect_tools) + app.connect("builder-inited", collect_prompts_and_resources) app.connect("doctree-read", register_tool_labels) app.connect("doctree-read", collect_tool_section_content) app.connect("doctree-resolved", add_section_badges) @@ -105,6 +114,10 @@ def _add_static_path(app: Sphinx) -> None: app.add_directive("fastmcp-tool", FastMCPToolDirective) app.add_directive("fastmcp-tool-input", FastMCPToolInputDirective) app.add_directive("fastmcp-tool-summary", FastMCPToolSummaryDirective) + app.add_directive("fastmcp-prompt", FastMCPPromptDirective) + app.add_directive("fastmcp-prompt-input", FastMCPPromptInputDirective) + app.add_directive("fastmcp-resource", FastMCPResourceDirective) + app.add_directive("fastmcp-resource-template", FastMCPResourceTemplateDirective) return { "version": _EXTENSION_VERSION, 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 841a4362..a76384bf 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py @@ -143,3 +143,105 @@ def build_toolbar(safety: str) -> nodes.inline: True """ return _sab_build_toolbar(build_tool_badge_group(safety)) + + +_TYPE_TOOLTIP_PROMPT = "MCP prompt recipe" +_TYPE_TOOLTIP_RESOURCE = "MCP resource (fixed URI)" +_TYPE_TOOLTIP_RESOURCE_TEMPLATE = "MCP resource template (parameterised URI)" +_ICON_PROMPT = "\U0001f4ac" # 💬 speech-balloon — prompts are conversation templates +_ICON_RESOURCE = "\U0001f5c2\ufe0f" # 🗂️ card-index — fixed-URI documents +_ICON_RESOURCE_TEMPLATE = "\U0001f9ed" # 🧭 compass — parameterised URIs + + +def build_prompt_badge_group(tags: t.Sequence[str] = ()) -> nodes.inline: + """Badge group: ``prompt`` type + optional tag pills. + + Examples + -------- + >>> g = build_prompt_badge_group(()) + >>> "gp-sphinx-badge-group" in g["classes"] + True + """ + specs = [ + BadgeSpec( + "prompt", + tooltip=_TYPE_TOOLTIP_PROMPT, + icon=_ICON_PROMPT, + classes=( + SAB.DENSE, + SAB.NO_UNDERLINE, + SAB.BADGE_TYPE, + _CSS.TYPE_PROMPT, + ), + ), + ] + for tag in tags: + specs.append( + BadgeSpec( + tag, + tooltip=f"Tag: {tag}", + classes=(SAB.DENSE, SAB.NO_UNDERLINE, _CSS.BADGE_TAG), + ), + ) + return build_badge_group_from_specs(specs) + + +def build_resource_badge_group( + mime_type: str, + tags: t.Sequence[str] = (), + *, + kind: t.Literal["resource", "resource-template"] = "resource", +) -> nodes.inline: + """Badge group for a resource or resource template. + + Emits a type badge (``resource`` or ``resource-template``), a MIME + pill if one is set, and optional tag pills. + + Examples + -------- + >>> g = build_resource_badge_group("application/json") + >>> "gp-sphinx-badge-group" in g["classes"] + True + """ + if kind == "resource-template": + type_spec = BadgeSpec( + "resource-template", + tooltip=_TYPE_TOOLTIP_RESOURCE_TEMPLATE, + icon=_ICON_RESOURCE_TEMPLATE, + classes=( + SAB.DENSE, + SAB.NO_UNDERLINE, + SAB.BADGE_TYPE, + _CSS.TYPE_RESOURCE_TEMPLATE, + ), + ) + else: + type_spec = BadgeSpec( + "resource", + tooltip=_TYPE_TOOLTIP_RESOURCE, + icon=_ICON_RESOURCE, + classes=( + SAB.DENSE, + SAB.NO_UNDERLINE, + SAB.BADGE_TYPE, + _CSS.TYPE_RESOURCE, + ), + ) + specs = [type_spec] + if mime_type: + specs.append( + BadgeSpec( + mime_type, + tooltip=f"MIME type: {mime_type}", + classes=(SAB.DENSE, SAB.NO_UNDERLINE, _CSS.BADGE_MIME), + ), + ) + for tag in tags: + specs.append( + BadgeSpec( + tag, + tooltip=f"Tag: {tag}", + classes=(SAB.DENSE, SAB.NO_UNDERLINE, _CSS.BADGE_TAG), + ), + ) + return build_badge_group_from_specs(specs) 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 5be99dfb..c2791bad 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py @@ -1,4 +1,4 @@ -"""Collect FastMCP tool metadata at Sphinx build time.""" +"""Collect FastMCP tool / prompt / resource metadata at Sphinx build time.""" from __future__ import annotations @@ -9,7 +9,13 @@ from sphinx.application import Sphinx -from sphinx_autodoc_fastmcp._models import ToolInfo +from sphinx_autodoc_fastmcp._models import ( + PromptArgInfo, + PromptInfo, + ResourceInfo, + ResourceTemplateInfo, + ToolInfo, +) from sphinx_autodoc_fastmcp._parsing import extract_params from sphinx_autodoc_typehints_gp import normalize_annotation_text @@ -188,3 +194,320 @@ def collect_tools(app: Sphinx) -> None: collector_tools.append(info) app.env.fastmcp_tools = {tool.name: tool for tool in collector_tools} # type: ignore[attr-defined] + + +def _resolve_server_instance(dotted: str) -> t.Any | None: + """Import ``module.attr`` and return a populated ``FastMCP`` instance. + + ``fastmcp_server_module`` may point at either: + + * a live ``FastMCP`` instance — used as-is + * a zero-argument callable that returns one — invoked once + * a module whose ``mcp`` attribute is a ``FastMCP`` instance that + has not yet called its registration hook (common when a server + exposes ``_register_all()`` and invokes it only from ``run_server``) + + In the last case we call ``register_all()`` / ``_register_all()`` on + the module after resolving the instance, so docs can enumerate the + same surface area as a running server. + """ + if not dotted: + return None + if ":" in dotted: + module_path, attr = dotted.split(":", 1) + else: + module_path, _, attr = dotted.rpartition(".") + if not module_path or not attr: + logger.warning( + "sphinx_autodoc_fastmcp: fastmcp_server_module %r has no attribute", + dotted, + ) + return None + try: + mod = importlib.import_module(module_path) + except Exception: + logger.warning( + "sphinx_autodoc_fastmcp: could not import server module %s", + module_path, + exc_info=True, + ) + return None + obj = getattr(mod, attr, None) + if obj is None: + return None + if callable(obj) and not hasattr(obj, "local_provider"): + try: + obj = obj() + except Exception: # pragma: no cover - defensive + logger.warning( + "sphinx_autodoc_fastmcp: calling %s() failed", dotted, exc_info=True, + ) + return None + if getattr(obj, "local_provider", None) is None: + # Catches both: (a) the configured attribute resolved directly to a + # non-FastMCP object, and (b) a factory callable that returned one. + # Without this guard ``_iter_components`` silently yields ``()`` and + # the user sees empty docs with no diagnostic. + logger.warning( + "sphinx_autodoc_fastmcp: %s did not resolve to a FastMCP instance " + "(no local_provider attribute); prompts/resources will be empty", + dotted, + ) + return None + # If the instance has no components yet, try to run the server's + # register-all hook so autodoc sees the same surface a live server does. + provider = getattr(obj, "local_provider", None) + if provider is not None: + components = getattr(provider, "_components", None) + if not components: + for hook_name in ("register_all", "_register_all"): + hook = getattr(mod, hook_name, None) + if callable(hook): + try: + hook() + except Exception: # pragma: no cover - defensive + logger.warning( + "sphinx_autodoc_fastmcp: %s.%s() raised", + module_path, + hook_name, + exc_info=True, + ) + break + return obj + + +def _iter_components(server: t.Any) -> t.Iterable[t.Any]: + """Yield every FastMCPComponent registered on ``server.local_provider``. + + Bypasses the async ``_list_*`` helpers and iterates the underlying + ``_components`` dict directly — the helpers are trivial type-filter + comprehensions, so reading ``_components.values()`` is equivalent and + avoids needing an event loop at Sphinx build time. + """ + provider = getattr(server, "local_provider", None) + if provider is None: + return () + components = getattr(provider, "_components", None) + if components is None: + return () + return tuple(components.values()) + + +_SCHEMA_NOTE_MARKER = "Provide as a JSON string matching the following schema:" + + +def _first_paragraph(text: str) -> str: + """Return the leading paragraph of a NumPy-style description. + + FastMCP copies the whole docstring into the ``description`` field + on registered components. For human-facing docs we want just the + opening paragraph — the ``Parameters`` / ``Returns`` sections are + rendered separately (for prompts) or hidden (for resources). + """ + if not text: + return "" + paragraphs = text.strip().split("\n\n") + return paragraphs[0].strip().replace("\n", " ") + + +def _strip_schema_note(text: str) -> str: + """Remove FastMCP's auto-appended JSON-schema hint from a description. + + FastMCP's prompt argument builder tacks on + ``"\\n\\nProvide as a JSON string matching the following schema: {...}"`` + to help LLMs; it's noise in human-facing docs. + """ + idx = text.find(_SCHEMA_NOTE_MARKER) + if idx == -1: + return text.strip() + return text[:idx].rstrip().rstrip("\n").strip() + + +def _prompt_from_component(prompt: t.Any) -> PromptInfo: + """Build a ``PromptInfo`` from a FastMCP ``Prompt`` component.""" + arguments: list[PromptArgInfo] = [] + for arg in getattr(prompt, "arguments", None) or []: + arguments.append( + PromptArgInfo( + name=str(arg.name), + description=_strip_schema_note(str(arg.description or "")), + required=bool(arg.required), + type_str="", + ), + ) + func = getattr(prompt, "fn", None) + if func is not None and hasattr(func, "__wrapped__"): + func = func.__wrapped__ + docstring = (func.__doc__ or "") if func is not None else "" + if func is not None and arguments: + # Enrich argument metadata with signature type annotations where + # possible so the rendered table can show types, not just names. + try: + sig = inspect.signature(func) + except (TypeError, ValueError): # pragma: no cover - defensive + sig = None + if sig is not None: + for arg in arguments: + param = sig.parameters.get(arg.name) + if param is not None: + arg.type_str = normalize_annotation_text(param.annotation) + tags = tuple(sorted(str(tag) for tag in getattr(prompt, "tags", None) or ())) + module_name = getattr(func, "__module__", "") if func is not None else "" + return PromptInfo( + name=str(prompt.name), + title=str(prompt.title or prompt.name), + description=_first_paragraph(str(prompt.description or "")), + docstring=docstring, + tags=tags, + arguments=arguments, + module_name=module_name, + ) + + +def _resource_from_component(res: t.Any) -> ResourceInfo: + """Build a ``ResourceInfo`` from a FastMCP ``Resource`` component.""" + func = getattr(res, "fn", None) + if func is not None and hasattr(func, "__wrapped__"): + func = func.__wrapped__ + docstring = (func.__doc__ or "") if func is not None else "" + tags = tuple(sorted(str(tag) for tag in getattr(res, "tags", None) or ())) + annotations = getattr(res, "annotations", None) + ann_dict: dict[str, t.Any] = {} + if annotations is not None: + for field_name in ( + "audience", + "priority", + "lastModified", + ): + val = getattr(annotations, field_name, None) + if val is not None: + ann_dict[field_name] = val + module_name = getattr(func, "__module__", "") if func is not None else "" + return ResourceInfo( + name=str(res.name), + uri=str(res.uri), + title=str(res.title or res.name), + description=_first_paragraph(str(res.description or "")), + mime_type=str(getattr(res, "mime_type", "") or ""), + docstring=docstring, + tags=tags, + annotations=ann_dict, + module_name=module_name, + ) + + +def _template_params_from_schema( + schema: dict[str, t.Any] | None, +) -> list[PromptArgInfo]: + """Flatten a JSON Schema ``properties`` dict into ``PromptArgInfo`` rows.""" + if not schema: + return [] + props = schema.get("properties") or {} + required = set(schema.get("required") or ()) + rows: list[PromptArgInfo] = [] + for name, subschema in props.items(): + if not isinstance(subschema, dict): + continue + type_str = str(subschema.get("type", "")) if subschema.get("type") else "" + # Anyof/oneof unions: join short type names. + if not type_str: + union = subschema.get("anyOf") or subschema.get("oneOf") or () + parts = [ + str(member.get("type", "")) + for member in union + if isinstance(member, dict) and member.get("type") + ] + if parts: + type_str = " | ".join(parts) + rows.append( + PromptArgInfo( + name=str(name), + description=str(subschema.get("description", "") or ""), + required=name in required, + type_str=type_str, + ), + ) + return rows + + +def _resource_template_from_component(tpl: t.Any) -> ResourceTemplateInfo: + """Build a ``ResourceTemplateInfo`` from a ``ResourceTemplate`` component.""" + func = getattr(tpl, "fn", None) + if func is not None and hasattr(func, "__wrapped__"): + func = func.__wrapped__ + docstring = (func.__doc__ or "") if func is not None else "" + tags = tuple(sorted(str(tag) for tag in getattr(tpl, "tags", None) or ())) + annotations = getattr(tpl, "annotations", None) + ann_dict: dict[str, t.Any] = {} + if annotations is not None: + for field_name in ("audience", "priority", "lastModified"): + val = getattr(annotations, field_name, None) + if val is not None: + ann_dict[field_name] = val + parameters = _template_params_from_schema(getattr(tpl, "parameters", None)) + module_name = getattr(func, "__module__", "") if func is not None else "" + return ResourceTemplateInfo( + name=str(tpl.name), + uri_template=str(tpl.uri_template), + title=str(tpl.title or tpl.name), + description=_first_paragraph(str(tpl.description or "")), + mime_type=str(getattr(tpl, "mime_type", "") or ""), + parameters=parameters, + docstring=docstring, + tags=tags, + annotations=ann_dict, + module_name=module_name, + ) + + +def collect_prompts_and_resources(app: Sphinx) -> None: + """Populate ``app.env.fastmcp_prompts`` / ``_resources`` / ``_resource_templates``. + + Imports ``fastmcp_server_module`` (e.g. ``"pkg.server:mcp"``) and + enumerates the live FastMCP instance's registered components. Does + nothing if the config value is unset. + """ + server_dotted = str(getattr(app.config, "fastmcp_server_module", "") or "") + prompts: dict[str, PromptInfo] = {} + resources: dict[str, ResourceInfo] = {} + templates: dict[str, ResourceTemplateInfo] = {} + + if server_dotted: + server = _resolve_server_instance(server_dotted) + if server is None: + logger.warning( + "sphinx_autodoc_fastmcp: fastmcp_server_module %r did not resolve " + "to a FastMCP instance; prompts/resources will be empty", + server_dotted, + ) + else: + try: + # Local imports so callers without fastmcp installed don't pay + # the import cost unless they actually point at a server. + from fastmcp.prompts.base import Prompt as _Prompt + from fastmcp.resources.base import Resource as _Resource + from fastmcp.resources.template import ( + ResourceTemplate as _ResourceTemplate, + ) + except Exception: # pragma: no cover - defensive + logger.warning( + "sphinx_autodoc_fastmcp: could not import fastmcp types", + exc_info=True, + ) + _Prompt = _Resource = _ResourceTemplate = None # type: ignore[assignment] + + if _Prompt is not None: + for component in _iter_components(server): + if isinstance(component, _ResourceTemplate): + info_tpl = _resource_template_from_component(component) + templates[info_tpl.name] = info_tpl + elif isinstance(component, _Resource): + info_res = _resource_from_component(component) + resources[info_res.name] = info_res + elif isinstance(component, _Prompt): + info_p = _prompt_from_component(component) + prompts[info_p.name] = info_p + + app.env.fastmcp_prompts = prompts # type: ignore[attr-defined] + app.env.fastmcp_resources = resources # type: ignore[attr-defined] + app.env.fastmcp_resource_templates = templates # type: ignore[attr-defined] 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 fd25da47..8aed7a7b 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py @@ -25,8 +25,19 @@ class _CSS: TOOL_SECTION = "gp-sphinx-fastmcp__tool-section" SECTION_TITLE_HIDDEN = "gp-sphinx-fastmcp__visually-hidden" TYPE_TOOL = "gp-sphinx-fastmcp__type-tool" + TYPE_PROMPT = "gp-sphinx-fastmcp__type-prompt" + TYPE_RESOURCE = "gp-sphinx-fastmcp__type-resource" + TYPE_RESOURCE_TEMPLATE = "gp-sphinx-fastmcp__type-resource-template" + BADGE_MIME = "gp-sphinx-fastmcp__mime" + BADGE_TAG = "gp-sphinx-fastmcp__tag" TOOL_ENTRY = "gp-sphinx-fastmcp__tool-entry" TOOL_SIGNATURE = "gp-sphinx-fastmcp__tool-signature" + PROMPT_SECTION = "gp-sphinx-fastmcp__prompt-section" + PROMPT_ENTRY = "gp-sphinx-fastmcp__prompt-entry" + PROMPT_SIGNATURE = "gp-sphinx-fastmcp__prompt-signature" + RESOURCE_SECTION = "gp-sphinx-fastmcp__resource-section" + RESOURCE_ENTRY = "gp-sphinx-fastmcp__resource-entry" + RESOURCE_SIGNATURE = "gp-sphinx-fastmcp__resource-signature" BODY_SECTION = "gp-sphinx-fastmcp__body-section" # Safety slot + tier values 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 d299dfac..52f6f0fb 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py @@ -5,9 +5,20 @@ from docutils import nodes from sphinx.util.docutils import SphinxDirective -from sphinx_autodoc_fastmcp._badges import build_tool_badge_group +from sphinx_autodoc_fastmcp._badges import ( + build_prompt_badge_group, + build_resource_badge_group, + build_tool_badge_group, +) from sphinx_autodoc_fastmcp._css import _CSS -from sphinx_autodoc_fastmcp._models import ParamInfo, ToolInfo +from sphinx_autodoc_fastmcp._models import ( + ParamInfo, + PromptArgInfo, + PromptInfo, + ResourceInfo, + ResourceTemplateInfo, + ToolInfo, +) from sphinx_autodoc_fastmcp._parsing import ( first_paragraph, make_para, @@ -267,3 +278,284 @@ def run(self) -> list[nodes.Node]: result_nodes.append(section) return result_nodes + + +def _arg_table( + args: list[PromptArgInfo], + *, + env: t.Any, + state: t.Any, + lineno: int, + heading_word: str, +) -> list[nodes.Node]: + """Build a Parameters/Arguments section for prompt or template args.""" + if not args: + return [] + from sphinx_autodoc_fastmcp._parsing import make_para, make_table, parse_rst_inline + from sphinx_autodoc_typehints_gp import build_annotation_display_paragraph + + headers = ["Argument", "Type", "Required", "Description"] + rows: list[list[str | nodes.Node]] = [] + for arg in args: + type_cell: str | nodes.Node = "—" + if arg.type_str: + try: + type_cell = build_annotation_display_paragraph(arg.type_str, env) + except Exception: # pragma: no cover - defensive + type_cell = make_para(nodes.literal("", arg.type_str)) + desc_node: nodes.Node + if arg.description: + desc_node = parse_rst_inline(arg.description, state, lineno) + else: + desc_node = nodes.paragraph("", "—") + rows.append( + [ + make_para(nodes.literal("", arg.name)), + type_cell, + "yes" if arg.required else "no", + desc_node, + ], + ) + return [ + make_para(nodes.strong("", heading_word)), + build_api_table_section( + API.PARAMETERS, + make_table(headers, rows, col_widths=[20, 20, 10, 50]), + ), + ] + + +import typing as t # noqa: E402 # used by helpers above & below + + +class FastMCPPromptDirective(SphinxDirective): + """Autodocument one MCP prompt: section + card body.""" + + required_arguments = 1 + optional_arguments = 0 + has_content = True + final_argument_whitespace = False + + def run(self) -> list[nodes.Node]: + """Build section with title + description for one prompt.""" + from sphinx_autodoc_fastmcp._parsing import first_paragraph, parse_rst_inline + + arg = self.arguments[0] + prompt_name = arg.split(".")[-1] if "." in arg else arg + prompts: dict[str, PromptInfo] = getattr(self.env, "fastmcp_prompts", {}) + prompt = prompts.get(prompt_name) + if prompt is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-prompt: prompt '{prompt_name}' not found. " + f"Available: {', '.join(sorted(prompts.keys()))}", + line=self.lineno, + ), + ] + + description = prompt.description or first_paragraph(prompt.docstring) + content_nodes: list[nodes.Node] = [] + if description: + content_nodes.append( + build_api_section( + API.DESCRIPTION, + parse_rst_inline(description, self.state, self.lineno), + classes=(_CSS.BODY_SECTION,), + ), + ) + + shell = nodes.container( + "", + classes=[_CSS.PROMPT_SECTION, API.CARD_SHELL], + ) + shell += build_api_card_entry( + profile_class=API.profile("fastmcp-prompt"), + signature_children=(nodes.literal("", prompt.name),), + content_children=tuple(content_nodes), + badge_group=build_prompt_badge_group(prompt.tags), + permalink=None, + entry_classes=(_CSS.PROMPT_ENTRY,), + signature_classes=(_CSS.PROMPT_SIGNATURE,), + ) + return [shell] + + +class FastMCPPromptInputDirective(SphinxDirective): + """Emit the argument table for a prompt.""" + + required_arguments = 1 + optional_arguments = 0 + has_content = False + + def run(self) -> list[nodes.Node]: + """Build argument table nodes.""" + arg = self.arguments[0] + prompt_name = arg.split(".")[-1] if "." in arg else arg + prompts: dict[str, PromptInfo] = getattr(self.env, "fastmcp_prompts", {}) + prompt = prompts.get(prompt_name) + if prompt is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-prompt-input: prompt '{prompt_name}' not found.", + line=self.lineno, + ), + ] + return _arg_table( + prompt.arguments, + env=self.env, + state=self.state, + lineno=self.lineno, + heading_word="Arguments", + ) + + +def _build_resource_card( + *, + state: t.Any, + lineno: int, + signature_text: str, + description: str, + docstring: str, + badge_group: nodes.inline, + mime_type: str, + shell_class: str, + entry_class: str, + signature_class: str, + profile_name: str, + extra_facts: list[tuple[str, nodes.Node]] | None = None, +) -> nodes.container: + """Shared card builder for resources & resource templates.""" + from sphinx_autodoc_fastmcp._parsing import first_paragraph, parse_rst_inline + + content_nodes: list[nodes.Node] = [] + body = description or first_paragraph(docstring) + if body: + content_nodes.append( + build_api_section( + API.DESCRIPTION, + parse_rst_inline(body, state, lineno), + classes=(_CSS.BODY_SECTION,), + ), + ) + + facts: list[ApiFactRow] = [] + if mime_type: + facts.append(ApiFactRow("MIME type", nodes.literal("", mime_type))) + for label, node in extra_facts or (): + facts.append(ApiFactRow(label, node)) + if facts: + content_nodes.append( + build_api_facts_section(facts, classes=(_CSS.BODY_SECTION,)), + ) + + shell = nodes.container("", classes=[shell_class, API.CARD_SHELL]) + shell += build_api_card_entry( + profile_class=API.profile(profile_name), + signature_children=(nodes.literal("", signature_text),), + content_children=tuple(content_nodes), + badge_group=badge_group, + permalink=None, + entry_classes=(entry_class,), + signature_classes=(signature_class,), + ) + return shell + + +class FastMCPResourceDirective(SphinxDirective): + """Autodocument one MCP resource (fixed URI).""" + + required_arguments = 1 + optional_arguments = 0 + has_content = True + final_argument_whitespace = False + + def run(self) -> list[nodes.Node]: + """Build section card for a resource.""" + arg = self.arguments[0] + name = arg.split(".")[-1] if "." in arg else arg + resources: dict[str, ResourceInfo] = getattr(self.env, "fastmcp_resources", {}) + res = resources.get(name) + if res is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-resource: resource '{name}' not found. " + f"Available: {', '.join(sorted(resources.keys()))}", + line=self.lineno, + ), + ] + return [ + _build_resource_card( + state=self.state, + lineno=self.lineno, + signature_text=res.uri, + description=res.description, + docstring=res.docstring, + badge_group=build_resource_badge_group( + res.mime_type, + res.tags, + kind="resource", + ), + mime_type=res.mime_type, + shell_class=_CSS.RESOURCE_SECTION, + entry_class=_CSS.RESOURCE_ENTRY, + signature_class=_CSS.RESOURCE_SIGNATURE, + profile_name="fastmcp-resource", + ), + ] + + +class FastMCPResourceTemplateDirective(SphinxDirective): + """Autodocument one MCP resource template (parameterised URI).""" + + required_arguments = 1 + optional_arguments = 0 + has_content = True + final_argument_whitespace = False + + def run(self) -> list[nodes.Node]: + """Build section card for a resource template.""" + arg = self.arguments[0] + name = arg.split(".")[-1] if "." in arg else arg + templates: dict[str, ResourceTemplateInfo] = getattr( + self.env, + "fastmcp_resource_templates", + {}, + ) + tpl = templates.get(name) + if tpl is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-resource-template: template '{name}' not found. " + f"Available: {', '.join(sorted(templates.keys()))}", + line=self.lineno, + ), + ] + card = _build_resource_card( + state=self.state, + lineno=self.lineno, + signature_text=tpl.uri_template, + description=tpl.description, + docstring=tpl.docstring, + badge_group=build_resource_badge_group( + tpl.mime_type, + tpl.tags, + kind="resource-template", + ), + mime_type=tpl.mime_type, + shell_class=_CSS.RESOURCE_SECTION, + entry_class=_CSS.RESOURCE_ENTRY, + signature_class=_CSS.RESOURCE_SIGNATURE, + profile_name="fastmcp-resource-template", + ) + result: list[nodes.Node] = [card] + if tpl.parameters: + result.extend( + _arg_table( + tpl.parameters, + env=self.env, + state=self.state, + lineno=self.lineno, + heading_word="Parameters", + ), + ) + return result 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 031f2f6f..0afd1cb3 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py @@ -1,9 +1,9 @@ -"""Data models for FastMCP tool documentation.""" +"""Data models for FastMCP tool / prompt / resource documentation.""" from __future__ import annotations import typing as t -from dataclasses import dataclass +from dataclasses import dataclass, field @dataclass @@ -33,10 +33,61 @@ class ToolInfo: return_annotation: str +@dataclass +class PromptArgInfo: + """One ``arguments[]`` entry on an MCP prompt.""" + + name: str + description: str + required: bool + type_str: str = "" + + +@dataclass +class PromptInfo: + """Collected metadata for a single MCP prompt. + + The underlying function is intentionally not retained — FastMCP + resources and prompts are frequently defined as closure-local + functions, which cannot be pickled into Sphinx's environment + cache. We extract the docstring eagerly at collect time. + """ + + name: str + title: str + description: str + docstring: str + tags: tuple[str, ...] + arguments: list[PromptArgInfo] + module_name: str = "" + + @dataclass class ResourceInfo: - """Placeholder for future FastMCP resource documentation.""" + """Collected metadata for a single MCP resource (fixed URI).""" + + name: str + uri: str + title: str + description: str + mime_type: str + docstring: str + tags: tuple[str, ...] = () + annotations: dict[str, t.Any] = field(default_factory=dict) + module_name: str = "" + + +@dataclass +class ResourceTemplateInfo: + """Collected metadata for a single MCP resource *template* (URI pattern).""" name: str - uri_template: str = "" + uri_template: str + title: str + description: str + mime_type: str + parameters: list[PromptArgInfo] + docstring: str + tags: tuple[str, ...] = () + annotations: dict[str, t.Any] = field(default_factory=dict) module_name: str = "" From 0ea93cb9b93fdb2b4416e083983f07f529d9aa7b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 17 Apr 2026 10:04:23 -0500 Subject: [PATCH 02/18] feat(sphinx-autodoc-fastmcp): prompt/resource anchors, IDs, badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompts and resources now match the tool directive's DX: - FastMCPPromptDirective creates nodes.section() with section_id (prompt.name with underscores → hyphens), registers it via note_explicit_target, and passes an api_permalink to the card entry. Callers no longer need outer ## headings — the hidden title populates the TOC sidebar and {ref} cross-refs resolve correctly. - _build_resource_card() gains optional section_id / document params; when set it wraps the card in a nodes.section() with the same anchor+permalink pattern. FastMCPResourceDirective and FastMCPResourceTemplateDirective pass section_id=name.replace("_","-"). - sphinx_autodoc_fastmcp.css: add :root variables and selector rules for type-prompt (violet), type-resource (emerald), type-resource-template (cyan), mime (gray), and tag (gray) badges, with light and dark mode variants matching the existing safety/tool-type pattern. TOC hide rule extended to cover all four type badges. --- .../src/sphinx_autodoc_fastmcp/_directives.py | 60 +++++- .../_static/css/sphinx_autodoc_fastmcp.css | 176 +++++++++++++++++- 2 files changed, 224 insertions(+), 12 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 52f6f0fb..a0caafa3 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py @@ -353,6 +353,21 @@ def run(self) -> list[nodes.Node]: ), ] + document = self.state.document + section_id = prompt.name.replace("_", "-") + section = nodes.section() + section["ids"].append(section_id) + section["classes"].extend((_CSS.PROMPT_SECTION, API.CARD_SHELL)) + document.note_explicit_target(section) + + title_node = nodes.title("", "") + title_node["classes"].append(_CSS.SECTION_TITLE_HIDDEN) + title_node += nodes.literal("", prompt.name) + section += title_node + + link = api_permalink(href=f"#{section_id}", title="Link to this prompt") + link["classes"] = ["headerlink", API.LINK] + description = prompt.description or first_paragraph(prompt.docstring) content_nodes: list[nodes.Node] = [] if description: @@ -364,20 +379,16 @@ def run(self) -> list[nodes.Node]: ), ) - shell = nodes.container( - "", - classes=[_CSS.PROMPT_SECTION, API.CARD_SHELL], - ) - shell += build_api_card_entry( + section += build_api_card_entry( profile_class=API.profile("fastmcp-prompt"), signature_children=(nodes.literal("", prompt.name),), content_children=tuple(content_nodes), badge_group=build_prompt_badge_group(prompt.tags), - permalink=None, + permalink=link, entry_classes=(_CSS.PROMPT_ENTRY,), signature_classes=(_CSS.PROMPT_SIGNATURE,), ) - return [shell] + return [section] class FastMCPPromptInputDirective(SphinxDirective): @@ -423,7 +434,9 @@ def _build_resource_card( signature_class: str, profile_name: str, extra_facts: list[tuple[str, nodes.Node]] | None = None, -) -> nodes.container: + section_id: str | None = None, + document: t.Any = None, +) -> nodes.Node: """Shared card builder for resources & resource templates.""" from sphinx_autodoc_fastmcp._parsing import first_paragraph, parse_rst_inline @@ -448,6 +461,33 @@ def _build_resource_card( build_api_facts_section(facts, classes=(_CSS.BODY_SECTION,)), ) + permalink: nodes.Node | None = None + if section_id and document is not None: + section = nodes.section() + section["ids"].append(section_id) + section["classes"].extend((shell_class, API.CARD_SHELL)) + document.note_explicit_target(section) + + title_node = nodes.title("", "") + title_node["classes"].append(_CSS.SECTION_TITLE_HIDDEN) + title_node += nodes.literal("", section_id.replace("-", "_")) + section += title_node + + link = api_permalink(href=f"#{section_id}", title="Link to this resource") + link["classes"] = ["headerlink", API.LINK] + permalink = link + + section += build_api_card_entry( + profile_class=API.profile(profile_name), + signature_children=(nodes.literal("", signature_text),), + content_children=tuple(content_nodes), + badge_group=badge_group, + permalink=permalink, + entry_classes=(entry_class,), + signature_classes=(signature_class,), + ) + return section + shell = nodes.container("", classes=[shell_class, API.CARD_SHELL]) shell += build_api_card_entry( profile_class=API.profile(profile_name), @@ -500,6 +540,8 @@ def run(self) -> list[nodes.Node]: entry_class=_CSS.RESOURCE_ENTRY, signature_class=_CSS.RESOURCE_SIGNATURE, profile_name="fastmcp-resource", + section_id=res.name.replace("_", "-"), + document=self.state.document, ), ] @@ -546,6 +588,8 @@ def run(self) -> list[nodes.Node]: entry_class=_CSS.RESOURCE_ENTRY, signature_class=_CSS.RESOURCE_SIGNATURE, profile_name="fastmcp-resource-template", + section_id=tpl.name.replace("_", "-"), + document=self.state.document, ) result: list[nodes.Node] = [card] if tpl.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 bd090bc1..9d43244f 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 @@ -21,6 +21,18 @@ --gp-sphinx-fastmcp-type-tool-bg: #0e7490; --gp-sphinx-fastmcp-type-tool-fg: #fff; --gp-sphinx-fastmcp-type-tool-border: #0f766e; + --gp-sphinx-fastmcp-type-prompt-bg: #7c3aed; + --gp-sphinx-fastmcp-type-prompt-bg-dark: #a78bfa; + --gp-sphinx-fastmcp-type-prompt-border: #6d28d9; + --gp-sphinx-fastmcp-type-prompt-border-dark: #c4b5fd; + --gp-sphinx-fastmcp-type-resource-bg: #059669; + --gp-sphinx-fastmcp-type-resource-bg-dark: #34d399; + --gp-sphinx-fastmcp-type-resource-border: #047857; + --gp-sphinx-fastmcp-type-resource-border-dark: #6ee7b7; + --gp-sphinx-fastmcp-type-resource-template-bg: #0891b2; + --gp-sphinx-fastmcp-type-resource-template-bg-dark: #22d3ee; + --gp-sphinx-fastmcp-type-resource-template-border: #0e7490; + --gp-sphinx-fastmcp-type-resource-template-border-dark: #a5f3fc; } /* ── Safety badges: gp-sphinx-badge--dense provides compact metrics; restore inline-flex for icon gap ── */ @@ -62,11 +74,96 @@ box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } +/* MCP "prompt": violet */ +.gp-sphinx-badge.gp-sphinx-fastmcp__type-prompt:not(.gp-sphinx-badge--inline-icon) { + background-color: #7c3aed !important; + color: #ffffff !important; + border: 1px solid #6d28d9 !important; + box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; +} + +/* MCP "resource": emerald */ +.gp-sphinx-badge.gp-sphinx-fastmcp__type-resource:not(.gp-sphinx-badge--inline-icon) { + background-color: #059669 !important; + color: #ffffff !important; + border: 1px solid #047857 !important; + box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; +} + +/* MCP "resource-template": cyan */ +.gp-sphinx-badge.gp-sphinx-fastmcp__type-resource-template:not(.gp-sphinx-badge--inline-icon) { + background-color: #0891b2 !important; + color: #ffffff !important; + border: 1px solid #0e7490 !important; + box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; +} + +/* MIME type and tag pills: neutral gray */ +.gp-sphinx-badge.gp-sphinx-fastmcp__mime:not(.gp-sphinx-badge--inline-icon), +.gp-sphinx-badge.gp-sphinx-fastmcp__tag:not(.gp-sphinx-badge--inline-icon) { + background-color: #6b7280 !important; + color: #ffffff !important; + border: 1px solid #4b5563 !important; + box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; +} + +/* ── Dark mode: override variables only; selectors above need not repeat ── */ + +@media (prefers-color-scheme: dark) { + body:not([data-theme="light"]) { + --gp-sphinx-fastmcp-type-tool-bg: #0d9488; + --gp-sphinx-fastmcp-type-tool-fg: #ffffff; + --gp-sphinx-fastmcp-type-tool-border: #14b8a6; + + --gp-sphinx-fastmcp-type-prompt-bg: #7c3aed; + --gp-sphinx-fastmcp-type-prompt-fg: #ffffff; + --gp-sphinx-fastmcp-type-prompt-border: #8b5cf6; + + --gp-sphinx-fastmcp-type-resource-bg: #2563eb; + --gp-sphinx-fastmcp-type-resource-fg: #ffffff; + --gp-sphinx-fastmcp-type-resource-border: #3b82f6; + + --gp-sphinx-fastmcp-type-resource-template-bg: #0284c7; + --gp-sphinx-fastmcp-type-resource-template-fg: #ffffff; + --gp-sphinx-fastmcp-type-resource-template-border: #0ea5e9; + + --gp-sphinx-fastmcp-mime-fg: #9ca3af; + --gp-sphinx-fastmcp-mime-border: #4b5563; + } +} + +body[data-theme="dark"] { + --gp-sphinx-fastmcp-type-tool-bg: #0d9488; + --gp-sphinx-fastmcp-type-tool-fg: #ffffff; + --gp-sphinx-fastmcp-type-tool-border: #14b8a6; + + --gp-sphinx-fastmcp-type-prompt-bg: #7c3aed; + --gp-sphinx-fastmcp-type-prompt-fg: #ffffff; + --gp-sphinx-fastmcp-type-prompt-border: #8b5cf6; + + --gp-sphinx-fastmcp-type-resource-bg: #2563eb; + --gp-sphinx-fastmcp-type-resource-fg: #ffffff; + --gp-sphinx-fastmcp-type-resource-border: #3b82f6; + + --gp-sphinx-fastmcp-type-resource-template-bg: #0284c7; + --gp-sphinx-fastmcp-type-resource-template-fg: #ffffff; + --gp-sphinx-fastmcp-type-resource-template-border: #0ea5e9; + + --gp-sphinx-fastmcp-mime-fg: #9ca3af; + --gp-sphinx-fastmcp-mime-border: #4b5563; +} + +/* Safety dark-mode box-shadow (safety 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__type-tool:not(.gp-sphinx-badge--inline-icon) { + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-tool:not(.gp-sphinx-badge--inline-icon), + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-prompt:not(.gp-sphinx-badge--inline-icon), + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource:not(.gp-sphinx-badge--inline-icon), + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource-template:not(.gp-sphinx-badge--inline-icon), + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__mime:not(.gp-sphinx-badge--inline-icon), + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__tag:not(.gp-sphinx-badge--inline-icon) { box-shadow: var(--gp-sphinx-badge-buff-shadow-dark-ui) !important; } @@ -75,12 +172,42 @@ color: #ffffff !important; border: 1px solid #14b8a6 !important; } + + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-prompt:not(.gp-sphinx-badge--inline-icon) { + background-color: #a78bfa !important; + color: #1e1b2e !important; + border: 1px solid #c4b5fd !important; + } + + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource:not(.gp-sphinx-badge--inline-icon) { + background-color: #34d399 !important; + color: #022c22 !important; + border: 1px solid #6ee7b7 !important; + } + + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource-template:not(.gp-sphinx-badge--inline-icon) { + background-color: #22d3ee !important; + color: #083344 !important; + border: 1px solid #a5f3fc !important; + } + + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__mime:not(.gp-sphinx-badge--inline-icon), + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__tag:not(.gp-sphinx-badge--inline-icon) { + background-color: #9ca3af !important; + color: #111827 !important; + border: 1px solid #d1d5db !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__type-tool:not(.gp-sphinx-badge--inline-icon) { +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-tool:not(.gp-sphinx-badge--inline-icon), +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-prompt:not(.gp-sphinx-badge--inline-icon), +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource:not(.gp-sphinx-badge--inline-icon), +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource-template:not(.gp-sphinx-badge--inline-icon), +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__mime:not(.gp-sphinx-badge--inline-icon), +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__tag:not(.gp-sphinx-badge--inline-icon) { box-shadow: var(--gp-sphinx-badge-buff-shadow-dark-ui) !important; } @@ -90,6 +217,31 @@ body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-tool:not(.gp-sp border: 1px solid #14b8a6 !important; } +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-prompt:not(.gp-sphinx-badge--inline-icon) { + background-color: #a78bfa !important; + color: #1e1b2e !important; + border: 1px solid #c4b5fd !important; +} + +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource:not(.gp-sphinx-badge--inline-icon) { + background-color: #34d399 !important; + color: #022c22 !important; + border: 1px solid #6ee7b7 !important; +} + +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource-template:not(.gp-sphinx-badge--inline-icon) { + background-color: #22d3ee !important; + color: #083344 !important; + border: 1px solid #a5f3fc !important; +} + +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__mime:not(.gp-sphinx-badge--inline-icon), +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__tag:not(.gp-sphinx-badge--inline-icon) { + background-color: #9ca3af !important; + color: #111827 !important; + border: 1px solid #d1d5db !important; +} + /* ── Emoji when data-icon absent (unicode); data-icon wins via badges base ── */ .gp-sphinx-fastmcp__safety-readonly:not([data-icon])::before { content: "\1F50D"; @@ -118,11 +270,27 @@ section.gp-sphinx-fastmcp__tool-section > .gp-sphinx-fastmcp__tool-entry > .gp-s margin-top: 0.75rem; } -/* Hide the "tool" type badge in TOC sidebar (redundant there). */ -.toc-tree .gp-sphinx-fastmcp__type-tool { +/* Hide type badges in TOC sidebar (redundant — the section title already names the item). */ +.toc-tree .gp-sphinx-fastmcp__type-tool, +.toc-tree .gp-sphinx-fastmcp__type-prompt, +.toc-tree .gp-sphinx-fastmcp__type-resource, +.toc-tree .gp-sphinx-fastmcp__type-resource-template { display: none !important; } +/* ── Prompt / resource section cards ──────────────────────── */ +section.gp-sphinx-fastmcp__prompt-section, +section.gp-sphinx-fastmcp__resource-section { + padding: 0; + overflow: clip; +} + +section.gp-sphinx-fastmcp__prompt-section > .gp-sphinx-fastmcp__prompt-entry > .gp-sphinx-api-header .gp-sphinx-api-signature, +section.gp-sphinx-fastmcp__resource-section > .gp-sphinx-fastmcp__resource-entry > .gp-sphinx-api-header .gp-sphinx-api-signature { + min-width: 0; + font-family: var(--font-stack--monospace); +} + .gp-sphinx-fastmcp__visually-hidden { position: absolute; width: 1px; From aa1ceb3d6c71504904346ec5c995951607440ce2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 17 Apr 2026 10:22:20 -0500 Subject: [PATCH 03/18] feat(sphinx-autodoc-fastmcp): sibling adoption for prompts/resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collect_tool_section_content previously only ran for sections with _CSS.TOOL_SECTION. Prompt and resource card sections were never adopted, so prose between directives (Use when, Why use this, sample renders, argument tables) floated outside the card container rather than being folded in. Add _CARD_SECTION_CLASSES frozenset covering TOOL_SECTION, PROMPT_SECTION, and RESOURCE_SECTION. The existing transform logic and _tool_content_container helper are generic enough to handle all three — the section structure is identical (api_component entry > content chain). --- .../src/sphinx_autodoc_fastmcp/_transforms.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 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 ec8edf2b..d66f7ff8 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py @@ -31,17 +31,23 @@ def _tool_content_container(section: nodes.section) -> nodes.Element: return section +_CARD_SECTION_CLASSES: frozenset[str] = frozenset( + {_CSS.TOOL_SECTION, _CSS.PROMPT_SECTION, _CSS.RESOURCE_SECTION} +) + + def collect_tool_section_content(app: Sphinx, doctree: nodes.document) -> None: - """Move siblings following each tool section into the section. + """Move siblings following each card section into the section. Directive-returned ``nodes.section`` is a closed node — MyST does not "enter" it. This transform runs after parsing and re-parents prose, - code blocks, and ``{fastmcp-tool-input}`` tables that sit between one - tool section and the next boundary (``---`` transition or another tool - section). + code blocks, and input tables that sit between one card section and the + next boundary (``---`` transition or another section). + + Covers tool, prompt, and resource card sections. """ for section in list(doctree.findall(nodes.section)): - if _CSS.TOOL_SECTION not in section.get("classes", []): + if not _CARD_SECTION_CLASSES.intersection(section.get("classes", [])): continue parent = section.parent if parent is None: From eb42115a1dbebf3bed154585f64e74af5b4996da Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 17 Apr 2026 17:54:15 -0500 Subject: [PATCH 04/18] fix(sphinx-ux-autodoc-layout): hide card-shell permalink (hover to show) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing dl.gp-sphinx-api-container hide/show pattern for card shells (tool, prompt, resource). Previously the ¶ link was always visible; now it appears only on header hover or :focus-visible. --- .../src/sphinx_ux_autodoc_layout/_static/css/layout.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css index bca4cfd3..d7387695 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css @@ -260,6 +260,15 @@ dl.gp-sphinx-api-container:not(.py) > dd.gp-sphinx-api-content { background: var(--color-api-background-hover); } +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-link { + visibility: hidden; +} + +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header:hover .gp-sphinx-api-link, +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-link:focus-visible { + visibility: visible; +} + .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout { display: flex; align-items: center; From 75cb028425b3c7fd3543d338bf4cc539ac809511 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 17 Apr 2026 18:17:41 -0500 Subject: [PATCH 05/18] mypy(fix[fastmcp]): add ignore_missing_imports override for fastmcp.* why: fastmcp is an intentional optional runtime dependency not declared in project deps; mypy reported import-not-found for fastmcp.prompts.base, fastmcp.resources.base, and fastmcp.resources.template with no way to suppress them. what: - Add [[tool.mypy.overrides]] for ["fastmcp", "fastmcp.*"] with ignore_missing_imports = true in pyproject.toml --- pyproject.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index b7d82af5..b0c96623 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,12 @@ disable_error_code = ["attr-defined", "unused-ignore"] module = ["sphinx_autodoc_pytest_fixtures", "sphinx_autodoc_pytest_fixtures.*", "tests.ext.pytest_fixtures.*"] ignore_errors = true +[[tool.mypy.overrides]] +# fastmcp is an optional runtime dependency; users without it installed still get +# full static analysis of everything else in sphinx-autodoc-fastmcp. +module = ["fastmcp", "fastmcp.*"] +ignore_missing_imports = true + [tool.ruff] target-version = "py310" extend-exclude = [ From 1df2fff180eaf9da0164d2e12b3b6b8c10c0147f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 17 Apr 2026 18:17:44 -0500 Subject: [PATCH 06/18] sphinx-autodoc-fastmcp(fix[collector]): drop stale type: ignore comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The # type: ignore[assignment] on the except-branch null assignment was unused — mypy never generated an assignment error there because the imported names were unbound in the except path. what: - Remove # type: ignore[assignment] from _collector.py line 486 --- .../src/sphinx_autodoc_fastmcp/_collector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c2791bad..bf7c545f 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py @@ -494,7 +494,7 @@ def collect_prompts_and_resources(app: Sphinx) -> None: "sphinx_autodoc_fastmcp: could not import fastmcp types", exc_info=True, ) - _Prompt = _Resource = _ResourceTemplate = None # type: ignore[assignment] + _Prompt = _Resource = _ResourceTemplate = None if _Prompt is not None: for component in _iter_components(server): From a85d419b4b8737a083fd07eb493479cb8aab5297 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 17 Apr 2026 18:44:44 -0500 Subject: [PATCH 07/18] sphinx-autodoc-fastmcp(fix[collector]): replace _first_paragraph with import why: _first_paragraph() duplicated first_paragraph() already in _parsing.py (identical split/strip/replace logic, no new behaviour); _parsing is already imported in this module. what: - Add first_paragraph to the _parsing import line - Delete private _first_paragraph() function - Replace all three call-sites with first_paragraph() --- .../src/sphinx_autodoc_fastmcp/_collector.py | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) 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 bf7c545f..3f48f138 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py @@ -16,7 +16,7 @@ ResourceTemplateInfo, ToolInfo, ) -from sphinx_autodoc_fastmcp._parsing import extract_params +from sphinx_autodoc_fastmcp._parsing import extract_params, first_paragraph from sphinx_autodoc_typehints_gp import normalize_annotation_text logger = logging.getLogger(__name__) @@ -296,20 +296,6 @@ def _iter_components(server: t.Any) -> t.Iterable[t.Any]: _SCHEMA_NOTE_MARKER = "Provide as a JSON string matching the following schema:" -def _first_paragraph(text: str) -> str: - """Return the leading paragraph of a NumPy-style description. - - FastMCP copies the whole docstring into the ``description`` field - on registered components. For human-facing docs we want just the - opening paragraph — the ``Parameters`` / ``Returns`` sections are - rendered separately (for prompts) or hidden (for resources). - """ - if not text: - return "" - paragraphs = text.strip().split("\n\n") - return paragraphs[0].strip().replace("\n", " ") - - def _strip_schema_note(text: str) -> str: """Remove FastMCP's auto-appended JSON-schema hint from a description. @@ -356,7 +342,7 @@ def _prompt_from_component(prompt: t.Any) -> PromptInfo: return PromptInfo( name=str(prompt.name), title=str(prompt.title or prompt.name), - description=_first_paragraph(str(prompt.description or "")), + description=first_paragraph(str(prompt.description or "")), docstring=docstring, tags=tags, arguments=arguments, @@ -387,7 +373,7 @@ def _resource_from_component(res: t.Any) -> ResourceInfo: name=str(res.name), uri=str(res.uri), title=str(res.title or res.name), - description=_first_paragraph(str(res.description or "")), + description=first_paragraph(str(res.description or "")), mime_type=str(getattr(res, "mime_type", "") or ""), docstring=docstring, tags=tags, @@ -450,7 +436,7 @@ def _resource_template_from_component(tpl: t.Any) -> ResourceTemplateInfo: name=str(tpl.name), uri_template=str(tpl.uri_template), title=str(tpl.title or tpl.name), - description=_first_paragraph(str(tpl.description or "")), + description=first_paragraph(str(tpl.description or "")), mime_type=str(getattr(tpl, "mime_type", "") or ""), parameters=parameters, docstring=docstring, From a47dbc17bcb36f021c8e23f7c7f4c1fda3747018 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 17 Apr 2026 18:45:53 -0500 Subject: [PATCH 08/18] sphinx-autodoc-fastmcp(fix[collector]): add doctests to pure helpers why: CLAUDE.md requires working doctests on all functions; _strip_schema_note and _template_params_from_schema were missing them. Also converts _strip_schema_note to r\""" to satisfy ruff D301 (backslash in docstring). what: - Add Examples section with two assertions to _strip_schema_note; convert docstring to r\""" and adjust \\n -> \n accordingly - Add Examples section with three assertions to _template_params_from_schema covering None, empty dict, and a minimal properties schema --- .../src/sphinx_autodoc_fastmcp/_collector.py | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) 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 3f48f138..d8e458ca 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py @@ -240,7 +240,9 @@ def _resolve_server_instance(dotted: str) -> t.Any | None: obj = obj() except Exception: # pragma: no cover - defensive logger.warning( - "sphinx_autodoc_fastmcp: calling %s() failed", dotted, exc_info=True, + "sphinx_autodoc_fastmcp: calling %s() failed", + dotted, + exc_info=True, ) return None if getattr(obj, "local_provider", None) is None: @@ -297,11 +299,18 @@ def _iter_components(server: t.Any) -> t.Iterable[t.Any]: def _strip_schema_note(text: str) -> str: - """Remove FastMCP's auto-appended JSON-schema hint from a description. + r"""Remove FastMCP's auto-appended JSON-schema hint from a description. FastMCP's prompt argument builder tacks on - ``"\\n\\nProvide as a JSON string matching the following schema: {...}"`` + ``"\n\nProvide as a JSON string matching the following schema: {...}"`` to help LLMs; it's noise in human-facing docs. + + Examples + -------- + >>> _strip_schema_note("Summary.") + 'Summary.' + >>> _strip_schema_note("Summary.\n\nProvide as a JSON string matching the following schema: {}") + 'Summary.' """ idx = text.find(_SCHEMA_NOTE_MARKER) if idx == -1: @@ -385,7 +394,17 @@ def _resource_from_component(res: t.Any) -> ResourceInfo: def _template_params_from_schema( schema: dict[str, t.Any] | None, ) -> list[PromptArgInfo]: - """Flatten a JSON Schema ``properties`` dict into ``PromptArgInfo`` rows.""" + """Flatten a JSON Schema ``properties`` dict into ``PromptArgInfo`` rows. + + Examples + -------- + >>> _template_params_from_schema(None) + [] + >>> _template_params_from_schema({}) + [] + >>> _template_params_from_schema({"properties": {"n": {"type": "string"}}, "required": ["n"]}) + [PromptArgInfo(name='n', description='', required=True, type_str='string')] + """ if not schema: return [] props = schema.get("properties") or {} From bc8183cfbc7b54a81c23d5ff0b5b22481feae587 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 19 Apr 2026 03:49:41 -0500 Subject: [PATCH 09/18] fix(_numpy_docstring): strip Raises roles, filter Notes invisibles why: Two bugs caused broken API docs in libtmux: 1. Notes sections containing only `.. todo: :` showed an empty rubric (todo renders as invisible HTML without todo_include_todos=True). 2. Raises sections using : exc:`ExcType` role syntax produced malformed RST like ':raises :exc:`exc.Foo`:' instead of a valid field list entry. what: - Add _ROLE_RE and _TODO_DIRECTIVE_RE compiled regex constants - Add _strip_roles() to strip RST inline role markup from exception type names - Add _filter_invisible_directives() to remove .. todo:: blocks and their indented body from generic section content - Fix _fmt_raises() to strip roles from type_ and split comma-separated exception names into individual :raises ExcType: entries - Fix _fmt_generic() to apply directive filtering and return [] (not [header, ""]) when filtered content is empty - Add test fixtures for exc-role raises, comma-separated raises, empty Notes, and Notes-only-todo cases --- .../_numpy_docstring.py | 61 ++++++++++++++++-- tests/ext/typehints_gp/test_unit.py | 63 +++++++++++++++++++ 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_numpy_docstring.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_numpy_docstring.py index 06767d4e..60799bfd 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_numpy_docstring.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_numpy_docstring.py @@ -36,6 +36,8 @@ import typing as t _NUMPY_UNDERLINE_RE = re.compile(r"^[=\-`:'\"~^_*+#<>]{2,}\s*$") +_ROLE_RE = re.compile(r":[a-zA-Z][a-zA-Z0-9_.-]*:`([^`]+)`") +_TODO_DIRECTIVE_RE = re.compile(r"^\.\.\s+todo\s*::") _XREF_OR_CODE_RE = re.compile( r"((?::(?:[a-zA-Z0-9]+[\-_+:.])*[a-zA-Z0-9]+:`.+?`)|" @@ -299,6 +301,45 @@ def _indent(lines: list[str], n: int = 4) -> list[str]: return [pad + line for line in lines] +def _strip_roles(s: str) -> str: + """Strip RST inline role markup, returning just the target text. + + Examples + -------- + >>> _strip_roles(":exc:`ValueError`") + 'ValueError' + >>> _strip_roles(":exc:`exc.Foo`, :exc:`exc.Bar`") + 'exc.Foo, exc.Bar' + >>> _strip_roles("plain") + 'plain' + """ + return _ROLE_RE.sub(r"\1", s) + + +def _filter_invisible_directives(lines: list[str]) -> list[str]: + """Remove ``.. todo::`` directives and their indented body from *lines*. + + Examples + -------- + >>> _filter_invisible_directives([".. todo::", "", " assure it works.", "text"]) + ['text'] + >>> _filter_invisible_directives(["keep this", ".. todo::", " body"]) + ['keep this'] + """ + result: list[str] = [] + skip_indent: int | None = None + for line in lines: + if skip_indent is not None: + if not line or _is_indented(line, skip_indent + 1): + continue + skip_indent = None + if _TODO_DIRECTIVE_RE.match(line.lstrip()): + skip_indent = _get_indent(line) + continue + result.append(line) + return result + + def _is_list(lines: list[str]) -> bool: """Return whether *lines* starts with a bullet or enumerated list.""" if not lines: @@ -669,10 +710,21 @@ def _fmt_raises(self) -> list[str]: fields = self._consume_fields(parse_type=False, prefer_type=True) lines: list[str] = [] for _name, type_, desc in fields: - type_str = f" {type_}" if type_ else "" + type_ = _strip_roles(type_) + exc_types = [ + part.strip().rstrip(",") + for part in type_.split(",") + if part.strip().rstrip(",") + ] + if not exc_types: + exc_types = [""] desc = _strip_empty(desc) - desc_str = " " + "\n ".join(desc) if any(desc) else "" - lines.append(f":raises{type_str}:{desc_str}") + for exc_type in exc_types: + type_str = f" {exc_type}" if exc_type else "" + if any(desc): + lines.extend(_format_block(f":raises{type_str}: ", desc)) + else: + lines.append(f":raises{type_str}:") if lines: lines.append("") return lines @@ -723,10 +775,11 @@ def _fmt_methods(self) -> list[str]: def _fmt_generic(self, label: str) -> list[str]: raw = _strip_empty(self._consume_to_next_section()) raw = _dedent(raw) + raw = _strip_empty(_filter_invisible_directives(raw)) header = f".. rubric:: {label}" if raw: return [header, "", *raw, ""] - return [header, ""] + return [] def _fmt_admonition(self, directive: str) -> list[str]: raw = _strip_empty(self._consume_to_next_section()) diff --git a/tests/ext/typehints_gp/test_unit.py b/tests/ext/typehints_gp/test_unit.py index 6332b332..3a9a2e49 100644 --- a/tests/ext/typehints_gp/test_unit.py +++ b/tests/ext/typehints_gp/test_unit.py @@ -804,6 +804,33 @@ class RaisesSectionFixture(t.NamedTuple): ], expected_in_output=[":raises TypeError:"], ), + RaisesSectionFixture( + test_id="raises_with_exc_role", + input_lines=[ + "Summary.", + "", + "Raises", + "------", + ":exc:`exc.LibTmuxException`", + ], + expected_in_output=[":raises exc.LibTmuxException:"], + ), + RaisesSectionFixture( + test_id="raises_multiple_comma_roles", + input_lines=[ + "Summary.", + "", + "Raises", + "------", + ":exc:`exc.OptionError`, :exc:`exc.UnknownOption`,", + ":exc:`exc.InvalidOption`", + ], + expected_in_output=[ + ":raises exc.OptionError:", + ":raises exc.UnknownOption:", + ":raises exc.InvalidOption:", + ], + ), ] @@ -904,6 +931,42 @@ def test_numpy_generic_section( assert fragment in joined, f"[{test_id}] Missing: {fragment!r}" +@pytest.mark.parametrize( + ("test_id", "input_lines"), + [ + ( + "notes_empty_content", + [ + "Summary.", + "", + "Notes", + "-----", + ], + ), + ( + "notes_only_todo", + [ + "Summary.", + "", + "Notes", + "-----", + ".. todo::", + "", + " assure it works.", + ], + ), + ], + ids=["notes_empty_content", "notes_only_todo"], +) +def test_numpy_generic_section_no_rubric(test_id: str, input_lines: list[str]) -> None: + """Notes section with no visible content produces no rubric node.""" + result = process_numpy_docstring(input_lines) + joined = "\n".join(result) + assert ".. rubric:: Notes" not in joined, ( + f"[{test_id}] Empty Notes rubric should not be emitted" + ) + + # --------------------------------------------------------------------------- # Special sections (See Also, Attributes, Admonitions) — parametrized # --------------------------------------------------------------------------- From 8ca05a2b19336b4eafeecd4b691d7b0f3bf74e0d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 19 Apr 2026 04:57:25 -0500 Subject: [PATCH 10/18] sphinx-autodoc-fastmcp(fix[badges,labels]): redesign + incremental labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Register tool/prompt/resource section labels in std domain at parse time (directive run()) so {ref} resolves them on incremental builds where doctree-read hooks never fire - Remove emoji icons from prompt and resource type badges; type text is self-sufficient (mirrors the existing tool badge pattern) - Restore resource-template as a distinct badge (sky blue #0369a1) alongside resource (steel blue #1e40af) and prompt (violet #5b21b6) - Refactor CSS to use CSS variables for all type badges; dark mode only overrides variables — selectors are written once - Ghost outline MIME/tag pills (transparent bg, muted border) - prompt/resource/resource-template keep light-mode colors in dark mode --- docs/packages/sphinx-autodoc-fastmcp.md | 67 +++++++- .../src/sphinx_autodoc_fastmcp/_badges.py | 17 +- .../src/sphinx_autodoc_fastmcp/_directives.py | 18 ++ .../_static/css/sphinx_autodoc_fastmcp.css | 160 ++++++------------ 4 files changed, 135 insertions(+), 127 deletions(-) diff --git a/docs/packages/sphinx-autodoc-fastmcp.md b/docs/packages/sphinx-autodoc-fastmcp.md index cb80fb88..8cd6afdb 100644 --- a/docs/packages/sphinx-autodoc-fastmcp.md +++ b/docs/packages/sphinx-autodoc-fastmcp.md @@ -36,7 +36,12 @@ fastmcp_tool_modules = [ fastmcp_area_map = { "fastmcp_tools": "api/tools", } -fastmcp_collector_mode = "introspect" +fastmcp_collector_mode = "register" + +# Optional: point at a live FastMCP server instance to autodoc its prompts, +# resources, and resource templates. Format is "module.path:attr_name". +# Both an instance and a zero-arg factory callable are accepted. +fastmcp_server_module = "my_project.server:mcp" ``` `sphinx_autodoc_fastmcp` automatically registers `sphinx_ux_badges`, @@ -49,12 +54,29 @@ You do not need to add them separately to your `extensions` list. |---------|---------|-------------| | `fastmcp_tool_modules` | `[]` | Python module paths that expose tool callables | | `fastmcp_area_map` | `{}` | Maps module stem to area path for ToC labels | -| `fastmcp_collector_mode` | `"introspect"` | `"introspect"` or `"register"` — how tools are discovered | +| `fastmcp_collector_mode` | `"register"` | `"register"` or `"introspect"` — how tools are discovered | +| `fastmcp_server_module` | `""` | `"module.path:attr"` — live FastMCP instance for prompt/resource autodoc | | `fastmcp_model_module` | `None` | Module containing Pydantic model classes | | `fastmcp_model_classes` | `set()` | Set of model class names to cross-reference | | `fastmcp_section_badge_map` | `{}` | Maps section names to safety badge labels | | `fastmcp_section_badge_pages` | `set()` | Pages where section safety badges are injected | +### `fastmcp_server_module` + +Pointing the collector at a live FastMCP instance enables autodoc of +**prompts**, **resources**, and **resource templates** — see the four new +directives below. The collector accepts either: + +* A live instance: `"my_project.server:mcp"` (where `mcp = FastMCP(...)`). +* A zero-argument factory: `"my_project.server:make_server"` returning a + `FastMCP` instance. + +If the resolved object is not a `FastMCP` (no `local_provider` attribute), +collection is skipped and a warning is logged. The collector also invokes +the server's `register_all` / `_register_all` hook (if exported) to +ensure components registered lazily appear in the docs; FastMCP's default +`on_duplicate="error"` policy is suppressed for this call. + ## Working usage examples Render one tool card: @@ -88,6 +110,47 @@ Use {tool}`list_sessions` for a linked badge, or {toolref}`delete_session` for a plain inline reference. ```` +### Prompts and resources + +After setting `fastmcp_server_module`, four MyST directives become available +for documenting MCP prompts and resources: + +````myst +```{fastmcp-prompt} my_prompt +``` + +```{fastmcp-prompt-input} my_prompt +``` + +```{fastmcp-resource} my_resource +``` + +```{fastmcp-resource-template} my_resource_template +``` +```` + +Resources and resource templates accept either the friendly component name +(`my_resource`) or the literal URI (`mem://my_resource`). When two +distinct resources share a name, autodoc keeps the first registration and +emits a warning — disambiguate by URI. + +### `:ref:` cross-reference IDs + +Section IDs follow `fastmcp-{kind}-{name}` (canonical): + +```text +{ref}`fastmcp-tool-list-sessions` +{ref}`fastmcp-prompt-greet` +{ref}`fastmcp-resource-status` +{ref}`fastmcp-resource-template-events-by-day` +``` + +Tool sections additionally register the bare slug as a back-compat alias +(e.g. `{ref}`list-sessions`` continues to resolve), preserving links +shipped before the kind-prefix introduction. Prompts, resources, and +resource templates use the canonical ID only — no bare alias is created +for them. + ## Live demos Use {tool}`list_sessions` for a linked badge, or {toolref}`delete_session` 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 a76384bf..995af20d 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py @@ -148,9 +148,6 @@ def build_toolbar(safety: str) -> nodes.inline: _TYPE_TOOLTIP_PROMPT = "MCP prompt recipe" _TYPE_TOOLTIP_RESOURCE = "MCP resource (fixed URI)" _TYPE_TOOLTIP_RESOURCE_TEMPLATE = "MCP resource template (parameterised URI)" -_ICON_PROMPT = "\U0001f4ac" # 💬 speech-balloon — prompts are conversation templates -_ICON_RESOURCE = "\U0001f5c2\ufe0f" # 🗂️ card-index — fixed-URI documents -_ICON_RESOURCE_TEMPLATE = "\U0001f9ed" # 🧭 compass — parameterised URIs def build_prompt_badge_group(tags: t.Sequence[str] = ()) -> nodes.inline: @@ -166,7 +163,6 @@ def build_prompt_badge_group(tags: t.Sequence[str] = ()) -> nodes.inline: BadgeSpec( "prompt", tooltip=_TYPE_TOOLTIP_PROMPT, - icon=_ICON_PROMPT, classes=( SAB.DENSE, SAB.NO_UNDERLINE, @@ -194,8 +190,8 @@ def build_resource_badge_group( ) -> nodes.inline: """Badge group for a resource or resource template. - Emits a type badge (``resource`` or ``resource-template``), a MIME - pill if one is set, and optional tag pills. + Emits a type badge (``resource`` or ``resource-template``), an optional + MIME pill, and optional tag pills. Examples -------- @@ -207,7 +203,6 @@ def build_resource_badge_group( type_spec = BadgeSpec( "resource-template", tooltip=_TYPE_TOOLTIP_RESOURCE_TEMPLATE, - icon=_ICON_RESOURCE_TEMPLATE, classes=( SAB.DENSE, SAB.NO_UNDERLINE, @@ -219,13 +214,7 @@ def build_resource_badge_group( type_spec = BadgeSpec( "resource", tooltip=_TYPE_TOOLTIP_RESOURCE, - icon=_ICON_RESOURCE, - classes=( - SAB.DENSE, - SAB.NO_UNDERLINE, - SAB.BADGE_TYPE, - _CSS.TYPE_RESOURCE, - ), + classes=(SAB.DENSE, SAB.NO_UNDERLINE, SAB.BADGE_TYPE, _CSS.TYPE_RESOURCE), ) specs = [type_spec] if mime_type: 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 a0caafa3..1e9bc2e2 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py @@ -42,6 +42,17 @@ ) +def _register_section_label(env: object, section_id: str, display_name: str) -> None: + """Register section in Sphinx std domain at parse time so {ref} resolves it. + + Must run inside a directive's run() — at that point env.docname is set and + domain.data is the live dict that gets pickled, surviving incremental builds. + """ + std = env.get_domain("std") # type: ignore[attr-defined] + std.anonlabels[section_id] = (env.docname, section_id) # type: ignore[attr-defined] + std.labels[section_id] = (env.docname, section_id, display_name) # type: ignore[attr-defined] + + class FastMCPToolDirective(SphinxDirective): """Autodocument one MCP tool: section (ToC/labels) + card body.""" @@ -77,6 +88,7 @@ def _build_tool_section(self, tool: ToolInfo) -> list[nodes.Node]: section = nodes.section() section["ids"].append(section_id) section["classes"].extend((_CSS.TOOL_SECTION, API.CARD_SHELL)) + _register_section_label(self.env, section_id, tool.name) document.note_explicit_target(section) title_node = nodes.title("", "") @@ -358,6 +370,7 @@ def run(self) -> list[nodes.Node]: section = nodes.section() section["ids"].append(section_id) section["classes"].extend((_CSS.PROMPT_SECTION, API.CARD_SHELL)) + _register_section_label(self.env, section_id, prompt.name) document.note_explicit_target(section) title_node = nodes.title("", "") @@ -466,6 +479,11 @@ def _build_resource_card( section = nodes.section() section["ids"].append(section_id) section["classes"].extend((shell_class, API.CARD_SHELL)) + _register_section_label( + state.document.settings.env, + section_id, + section_id.replace("-", "_"), + ) document.note_explicit_target(section) title_node = nodes.title("", "") 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 9d43244f..e84c0e58 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 @@ -6,9 +6,13 @@ * * Safety palette: same tokens as sphinx_gp_theme/theme/static/css/custom.css * so readonly / mutating / destructive match 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 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; @@ -18,21 +22,31 @@ --gp-sphinx-fastmcp-safety-destructive-bg: #b4232c; --gp-sphinx-fastmcp-safety-destructive-border: #cb3640; --gp-sphinx-fastmcp-safety-destructive-text: #fff5f5; + + /* ── Type: tool — teal ── */ --gp-sphinx-fastmcp-type-tool-bg: #0e7490; - --gp-sphinx-fastmcp-type-tool-fg: #fff; + --gp-sphinx-fastmcp-type-tool-fg: #ffffff; --gp-sphinx-fastmcp-type-tool-border: #0f766e; - --gp-sphinx-fastmcp-type-prompt-bg: #7c3aed; - --gp-sphinx-fastmcp-type-prompt-bg-dark: #a78bfa; - --gp-sphinx-fastmcp-type-prompt-border: #6d28d9; - --gp-sphinx-fastmcp-type-prompt-border-dark: #c4b5fd; - --gp-sphinx-fastmcp-type-resource-bg: #059669; - --gp-sphinx-fastmcp-type-resource-bg-dark: #34d399; - --gp-sphinx-fastmcp-type-resource-border: #047857; - --gp-sphinx-fastmcp-type-resource-border-dark: #6ee7b7; - --gp-sphinx-fastmcp-type-resource-template-bg: #0891b2; - --gp-sphinx-fastmcp-type-resource-template-bg-dark: #22d3ee; - --gp-sphinx-fastmcp-type-resource-template-border: #0e7490; - --gp-sphinx-fastmcp-type-resource-template-border-dark: #a5f3fc; + + /* ── Type: prompt — muted violet ── */ + --gp-sphinx-fastmcp-type-prompt-bg: #5b21b6; + --gp-sphinx-fastmcp-type-prompt-fg: #ffffff; + --gp-sphinx-fastmcp-type-prompt-border: #4c1d95; + + /* ── Type: resource (fixed URI) — steel blue ── */ + --gp-sphinx-fastmcp-type-resource-bg: #1e40af; + --gp-sphinx-fastmcp-type-resource-fg: #ffffff; + --gp-sphinx-fastmcp-type-resource-border: #1e3a8a; + + /* ── Type: resource-template (parameterised URI) — sky blue ── */ + --gp-sphinx-fastmcp-type-resource-template-bg: #0369a1; + --gp-sphinx-fastmcp-type-resource-template-fg: #ffffff; + --gp-sphinx-fastmcp-type-resource-template-border: #075985; + + /* ── MIME / tag ghost pill ── */ + --gp-sphinx-fastmcp-mime-bg: transparent; + --gp-sphinx-fastmcp-mime-fg: #6b7280; + --gp-sphinx-fastmcp-mime-border: #d1d5db; } /* ── Safety badges: gp-sphinx-badge--dense provides compact metrics; restore inline-flex for icon gap ── */ @@ -66,45 +80,43 @@ box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } -/* MCP "tool": white label (never use --sd-color-info-text; it is dark on light teal) */ +/* ── 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) { - background-color: #0e7490 !important; - color: #ffffff !important; - border: 1px solid #0f766e !important; + background-color: var(--gp-sphinx-fastmcp-type-tool-bg) !important; + color: var(--gp-sphinx-fastmcp-type-tool-fg) !important; + border: 1px solid var(--gp-sphinx-fastmcp-type-tool-border) !important; box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } -/* MCP "prompt": violet */ .gp-sphinx-badge.gp-sphinx-fastmcp__type-prompt:not(.gp-sphinx-badge--inline-icon) { - background-color: #7c3aed !important; - color: #ffffff !important; - border: 1px solid #6d28d9 !important; + background-color: var(--gp-sphinx-fastmcp-type-prompt-bg) !important; + color: var(--gp-sphinx-fastmcp-type-prompt-fg) !important; + border: 1px solid var(--gp-sphinx-fastmcp-type-prompt-border) !important; box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } -/* MCP "resource": emerald */ .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource:not(.gp-sphinx-badge--inline-icon) { - background-color: #059669 !important; - color: #ffffff !important; - border: 1px solid #047857 !important; + background-color: var(--gp-sphinx-fastmcp-type-resource-bg) !important; + color: var(--gp-sphinx-fastmcp-type-resource-fg) !important; + border: 1px solid var(--gp-sphinx-fastmcp-type-resource-border) !important; box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } -/* MCP "resource-template": cyan */ .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource-template:not(.gp-sphinx-badge--inline-icon) { - background-color: #0891b2 !important; - color: #ffffff !important; - border: 1px solid #0e7490 !important; + background-color: var(--gp-sphinx-fastmcp-type-resource-template-bg) !important; + color: var(--gp-sphinx-fastmcp-type-resource-template-fg) !important; + border: 1px solid var(--gp-sphinx-fastmcp-type-resource-template-border) !important; box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } -/* MIME type and tag pills: neutral gray */ +/* MIME type and tag pills: ghost outline — whispers rather than shouts */ .gp-sphinx-badge.gp-sphinx-fastmcp__mime:not(.gp-sphinx-badge--inline-icon), .gp-sphinx-badge.gp-sphinx-fastmcp__tag:not(.gp-sphinx-badge--inline-icon) { - background-color: #6b7280 !important; - color: #ffffff !important; - border: 1px solid #4b5563 !important; - box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; + background-color: var(--gp-sphinx-fastmcp-mime-bg) !important; + color: var(--gp-sphinx-fastmcp-mime-fg) !important; + border: 1px solid var(--gp-sphinx-fastmcp-mime-border) !important; + box-shadow: none !important; } /* ── Dark mode: override variables only; selectors above need not repeat ── */ @@ -157,92 +169,18 @@ body[data-theme="dark"] { @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__type-tool:not(.gp-sphinx-badge--inline-icon), - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-prompt:not(.gp-sphinx-badge--inline-icon), - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource:not(.gp-sphinx-badge--inline-icon), - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource-template:not(.gp-sphinx-badge--inline-icon), - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__mime:not(.gp-sphinx-badge--inline-icon), - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__tag: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) { box-shadow: var(--gp-sphinx-badge-buff-shadow-dark-ui) !important; } - - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-tool:not(.gp-sphinx-badge--inline-icon) { - background-color: #0d9488 !important; - color: #ffffff !important; - border: 1px solid #14b8a6 !important; - } - - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-prompt:not(.gp-sphinx-badge--inline-icon) { - background-color: #a78bfa !important; - color: #1e1b2e !important; - border: 1px solid #c4b5fd !important; - } - - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource:not(.gp-sphinx-badge--inline-icon) { - background-color: #34d399 !important; - color: #022c22 !important; - border: 1px solid #6ee7b7 !important; - } - - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource-template:not(.gp-sphinx-badge--inline-icon) { - background-color: #22d3ee !important; - color: #083344 !important; - border: 1px solid #a5f3fc !important; - } - - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__mime:not(.gp-sphinx-badge--inline-icon), - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__tag:not(.gp-sphinx-badge--inline-icon) { - background-color: #9ca3af !important; - color: #111827 !important; - border: 1px solid #d1d5db !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__type-tool:not(.gp-sphinx-badge--inline-icon), -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-prompt:not(.gp-sphinx-badge--inline-icon), -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource:not(.gp-sphinx-badge--inline-icon), -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource-template:not(.gp-sphinx-badge--inline-icon), -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__mime:not(.gp-sphinx-badge--inline-icon), -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__tag:not(.gp-sphinx-badge--inline-icon) { +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__safety-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__type-tool:not(.gp-sphinx-badge--inline-icon) { - background-color: #0d9488 !important; - color: #ffffff !important; - border: 1px solid #14b8a6 !important; -} - -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-prompt:not(.gp-sphinx-badge--inline-icon) { - background-color: #a78bfa !important; - color: #1e1b2e !important; - border: 1px solid #c4b5fd !important; -} - -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource:not(.gp-sphinx-badge--inline-icon) { - background-color: #34d399 !important; - color: #022c22 !important; - border: 1px solid #6ee7b7 !important; -} - -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-resource-template:not(.gp-sphinx-badge--inline-icon) { - background-color: #22d3ee !important; - color: #083344 !important; - border: 1px solid #a5f3fc !important; -} - -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__mime:not(.gp-sphinx-badge--inline-icon), -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__tag:not(.gp-sphinx-badge--inline-icon) { - background-color: #9ca3af !important; - color: #111827 !important; - border: 1px solid #d1d5db !important; -} - -/* ── Emoji when data-icon absent (unicode); data-icon wins via badges base ── */ +/* ── Emoji fallback (CSS ::before) when data-icon absent ── */ .gp-sphinx-fastmcp__safety-readonly:not([data-icon])::before { content: "\1F50D"; } From 83ba28adced97be67ce609adca9d2351088483e8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 19 Apr 2026 06:42:20 -0500 Subject: [PATCH 11/18] sphinx-gp-theme(feat[spa]): dispatch gp-sphinx:navigated CustomEvent on DOM swap Public hook so third-party widgets that bind to swapped DOM can re-initialise after SPA navigation without a full page reload. Fires after the built-in reinit (copybutton, scroll-spy, theme toggle) has run. Payload: event.detail.url = the new page URL. Event contract documented in packages/sphinx-gp-theme/README.md under a new "JavaScript events" section (table + minimal listener pattern) and in CHANGES under the unreleased Features block. --- CHANGES | 4 +++ packages/sphinx-gp-theme/README.md | 25 +++++++++++++++++++ .../theme/static/js/spa-nav.js | 10 ++++++++ 3 files changed, 39 insertions(+) diff --git a/CHANGES b/CHANGES index 3ecf510e..987be6c9 100644 --- a/CHANGES +++ b/CHANGES @@ -31,6 +31,10 @@ $ uv add gp-sphinx --prerelease allow ### Features +- `sphinx-gp-theme`: dispatch `gp-sphinx:navigated` CustomEvent on + `document` after every SPA-nav DOM swap. Third-party widgets that bind to + swapped DOM can listen for this event to re-initialise after navigation + without a full page reload. Payload: `event.detail.url` is the new URL. - `sphinx-autodoc-fastmcp`: autodoc MCP prompts and resources end-to-end. Four new directives — ``{fastmcp-prompt}``, ``{fastmcp-prompt-input}``, diff --git a/packages/sphinx-gp-theme/README.md b/packages/sphinx-gp-theme/README.md index 44b477be..e9289554 100644 --- a/packages/sphinx-gp-theme/README.md +++ b/packages/sphinx-gp-theme/README.md @@ -21,3 +21,28 @@ html_theme = "sphinx-gp-theme" ``` Or use with [gp-sphinx](https://gp-sphinx.git-pull.com) which sets the theme automatically. + +## JavaScript events + +The bundled SPA-navigation layer dispatches the following `CustomEvent`s on +`document`: + +| Event | When | `event.detail` | +|-------|------|----------------| +| `gp-sphinx:navigated` | After an SPA page swap completes — the new `.article-container` / `.sidebar-tree` / `.toc-drawer` are in the DOM and the built-in reinit (copybutton, scroll-spy, theme toggle) has run. | `{ url: string }` — the new page URL. | + +Widgets that bind event listeners to DOM inside `.article-container` should +listen for `gp-sphinx:navigated` in addition to `DOMContentLoaded`, because +that region is replaced in-place on every link click and old listeners are +destroyed along with it. Listeners on `document` or `window` (including those +added inside the handler) persist across swaps. + +Minimal pattern: + +```javascript +function init() { + document.querySelectorAll(".my-widget").forEach(/* ... */); +} +document.addEventListener("DOMContentLoaded", init); +document.addEventListener("gp-sphinx:navigated", init); +``` diff --git a/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/js/spa-nav.js b/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/js/spa-nav.js index 4f45b313..556fffe9 100644 --- a/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/js/spa-nav.js +++ b/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/js/spa-nav.js @@ -187,6 +187,16 @@ initScrollSpy(); var btn = document.querySelector(".content-icon-container .theme-toggle"); if (btn) btn.addEventListener("click", cycleTheme); + + // Public hook: widgets that bind to swapped DOM re-initialise from here. + // Dispatched after the article/sidebar/TOC regions have been replaced and + // every built-in reinit step (copybutton, scroll-spy, theme toggle) has + // run. Consumers: `document.addEventListener("gp-sphinx:navigated", fn)`. + document.dispatchEvent( + new CustomEvent("gp-sphinx:navigated", { + detail: { url: location.href }, + }), + ); } // --- Navigation --- From ebbde09cd0288d0531d697aceb5d5b95a0f424cf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 19 Apr 2026 07:30:58 -0500 Subject: [PATCH 12/18] sphinx-autodoc-fastmcp(fix[directives]): typed std-domain + cleanup why: AGENTS.md mandates env.domains.standard_domain over env.get_domain("std"). The same package's _transforms.py already uses the typed accessor, so removing the three # type: ignore[attr-defined] is mechanical. While here, fold the round-trip section_id.replace("-", "_") display-name derivation back to the original resource name, hoist the mid-file "import typing as t" to the top, and drop function-local re-imports of names already imported at module top. what: - Annotate _register_section_label env parameter as BuildEnvironment; use env.domains.standard_domain (drops three # type: ignore[attr-defined]) - Thread display_name kwarg through _build_resource_card; resource/template directives now pass res.name / tpl.name verbatim - Hoist "import typing as t" to top-of-file imports; drop the # noqa: E402 placement - Remove function-local re-imports of first_paragraph, parse_rst_inline, make_para, make_table, build_annotation_display_paragraph (already imported at top) --- .../src/sphinx_autodoc_fastmcp/_collector.py | 66 +++++- .../src/sphinx_autodoc_fastmcp/_directives.py | 214 ++++++++++++------ .../src/sphinx_autodoc_fastmcp/_transforms.py | 30 ++- 3 files changed, 232 insertions(+), 78 deletions(-) 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 d8e458ca..c2e04893 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py @@ -225,7 +225,7 @@ def _resolve_server_instance(dotted: str) -> t.Any | None: return None try: mod = importlib.import_module(module_path) - except Exception: + except (ImportError, ModuleNotFoundError): logger.warning( "sphinx_autodoc_fastmcp: could not import server module %s", module_path, @@ -471,11 +471,38 @@ def collect_prompts_and_resources(app: Sphinx) -> None: Imports ``fastmcp_server_module`` (e.g. ``"pkg.server:mcp"``) and enumerates the live FastMCP instance's registered components. Does nothing if the config value is unset. + + Examples + -------- + >>> import types + >>> app = types.SimpleNamespace( + ... config=types.SimpleNamespace(fastmcp_server_module=""), + ... env=types.SimpleNamespace(), + ... ) + >>> collect_prompts_and_resources(app) + >>> app.env.fastmcp_prompts + {} + >>> app.env.fastmcp_resources + {} + >>> app.env.fastmcp_resource_names + {} + >>> app.env.fastmcp_resource_templates + {} + >>> app.env.fastmcp_resource_template_names + {} """ server_dotted = str(getattr(app.config, "fastmcp_server_module", "") or "") prompts: dict[str, PromptInfo] = {} + # Resources and templates are URI-keyed because FastMCP itself keys by + # ``str(uri)`` / ``uri_template`` (see fastmcp/resources/base.py:Resource.key). + # Two distinct resources sharing a ``.name`` would silently overwrite each + # other if we keyed by name. The companion ``*_names`` dicts let + # ``{fastmcp-resource} my_resource`` directives resolve by friendly name + # while still preserving URI identity for collisions. resources: dict[str, ResourceInfo] = {} + resource_names: dict[str, str] = {} templates: dict[str, ResourceTemplateInfo] = {} + template_names: dict[str, str] = {} if server_dotted: server = _resolve_server_instance(server_dotted) @@ -505,14 +532,47 @@ def collect_prompts_and_resources(app: Sphinx) -> None: for component in _iter_components(server): if isinstance(component, _ResourceTemplate): info_tpl = _resource_template_from_component(component) - templates[info_tpl.name] = info_tpl + key_tpl = str(info_tpl.uri_template) + templates[key_tpl] = info_tpl + _index_by_name( + template_names, info_tpl.name, key_tpl, "template" + ) elif isinstance(component, _Resource): info_res = _resource_from_component(component) - resources[info_res.name] = info_res + key_res = str(info_res.uri) + resources[key_res] = info_res + _index_by_name( + resource_names, info_res.name, key_res, "resource" + ) elif isinstance(component, _Prompt): info_p = _prompt_from_component(component) prompts[info_p.name] = info_p app.env.fastmcp_prompts = prompts # type: ignore[attr-defined] app.env.fastmcp_resources = resources # type: ignore[attr-defined] + app.env.fastmcp_resource_names = resource_names # type: ignore[attr-defined] app.env.fastmcp_resource_templates = templates # type: ignore[attr-defined] + app.env.fastmcp_resource_template_names = template_names # type: ignore[attr-defined] + + +def _index_by_name(name_index: dict[str, str], name: str, key: str, kind: str) -> None: + """Record ``name -> key`` mapping; warn on first-wins collision. + + FastMCP allows two distinct resources/templates to share a display name + while remaining keyed apart by URI. Authoring docs by name is convenient + but ambiguous in that case — first-wins, with a clear warning so users + know to disambiguate by URI. + """ + existing = name_index.get(name) + if existing is not None and existing != key: + logger.warning( + "sphinx_autodoc_fastmcp: %s name %r is ambiguous " + "(%s and %s share it); resolving to %s", + kind, + name, + existing, + key, + existing, + ) + return + name_index[name] = key 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 1e9bc2e2..7c39a6bb 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py @@ -2,9 +2,15 @@ from __future__ import annotations +import logging +import typing as t + from docutils import nodes from sphinx.util.docutils import SphinxDirective +if t.TYPE_CHECKING: + from sphinx.environment import BuildEnvironment + from sphinx_autodoc_fastmcp._badges import ( build_prompt_badge_group, build_resource_badge_group, @@ -41,16 +47,108 @@ build_api_table_section, ) +logger = logging.getLogger(__name__) + -def _register_section_label(env: object, section_id: str, display_name: str) -> None: +def _register_section_label( + env: BuildEnvironment, section_id: str, display_name: str +) -> None: """Register section in Sphinx std domain at parse time so {ref} resolves it. Must run inside a directive's run() — at that point env.docname is set and domain.data is the live dict that gets pickled, surviving incremental builds. + + Direct dict writes mirror the same-package pattern in + :func:`sphinx_autodoc_fastmcp._transforms.register_tool_labels`; Sphinx + flags ``StandardDomain.note_hyperlink_target`` as internal-use-only. + + Examples + -------- + >>> import types + >>> std = types.SimpleNamespace(labels={}, anonlabels={}) + >>> domains = types.SimpleNamespace(standard_domain=std) + >>> env = types.SimpleNamespace(docname="api", domains=domains) + >>> _register_section_label(env, "fastmcp-tool-foo", "foo") + >>> std.labels["fastmcp-tool-foo"] + ('api', 'fastmcp-tool-foo', 'foo') + >>> std.anonlabels["fastmcp-tool-foo"] + ('api', 'fastmcp-tool-foo') + """ + std = env.domains.standard_domain + std.anonlabels[section_id] = (env.docname, section_id) + std.labels[section_id] = (env.docname, section_id, display_name) + + +def _component_ids(kind: str, name: str) -> tuple[str, list[str]]: + """Derive canonical section id + back-compat aliases for a component. + + Canonical IDs always namespace by kind so a tool ``status`` and a + prompt ``status`` cannot collide in ``std.labels``. Tools additionally + keep the bare slug as an alias because their unprefixed IDs were the + public ``{ref}`` shape on ``main`` and live in downstream user docs; + prompts/resources/templates are new in this branch and have no such + history, so they get the canonical ID only. + + Examples + -------- + >>> _component_ids("tool", "list_sessions") + ('fastmcp-tool-list-sessions', ['list-sessions']) + >>> _component_ids("prompt", "greet_user") + ('fastmcp-prompt-greet-user', []) + >>> _component_ids("resource-template", "events_by_day") + ('fastmcp-resource-template-events-by-day', []) + """ + slug = name.replace("_", "-") + canonical = f"fastmcp-{kind}-{slug}" + aliases: list[str] = [slug] if kind == "tool" else [] + return canonical, aliases + + +def _register_alias_if_free( + env: BuildEnvironment, + *, + alias: str, + display_name: str, + kind: str, +) -> bool: + """Register a bare-slug alias in std.labels iff currently unclaimed. + + Aliases are tool-only by policy (back-compat with v1 ``{ref}`` URLs). + Calling this for any other kind is a programming error — raises + :class:`ValueError`. If the alias is already bound to a different + target, log WARNING and return ``False`` (canonical-only, no + silent overwrite). + + The alias maps to itself (``std.labels[alias] = (docname, alias, ...)``); + the bare slug is also pushed onto ``section["ids"]`` so the alias + resolves to a real HTML anchor without forcing existing ``:ref:`` + consumers to update href fragments. + + Returns True if the alias was registered, False if skipped. """ - std = env.get_domain("std") # type: ignore[attr-defined] - std.anonlabels[section_id] = (env.docname, section_id) # type: ignore[attr-defined] - std.labels[section_id] = (env.docname, section_id, display_name) # type: ignore[attr-defined] + if kind != "tool": + msg = f"alias registration not permitted for kind={kind!r}" + raise ValueError(msg) + + std = env.domains.standard_domain + existing = std.labels.get(alias) or std.anonlabels.get(alias) + if existing is not None: + existing_doc = existing[0] + existing_id = existing[1] + if (existing_doc, existing_id) != (env.docname, alias): + logger.warning( + "sphinx_autodoc_fastmcp: bare alias %r for %s already claimed " + "by %s#%s; using canonical id only", + alias, + display_name, + existing_doc, + existing_id, + ) + return False + + std.anonlabels[alias] = (env.docname, alias) + std.labels[alias] = (env.docname, alias, display_name) + return True class FastMCPToolDirective(SphinxDirective): @@ -83,12 +181,20 @@ def run(self) -> list[nodes.Node]: def _build_tool_section(self, tool: ToolInfo) -> list[nodes.Node]: """Build section card with shared API layout regions.""" document = self.state.document - section_id = tool.name.replace("_", "-") + section_id, aliases = _component_ids("tool", tool.name) section = nodes.section() section["ids"].append(section_id) + section["ids"].extend(aliases) section["classes"].extend((_CSS.TOOL_SECTION, API.CARD_SHELL)) _register_section_label(self.env, section_id, tool.name) + for alias in aliases: + _register_alias_if_free( + self.env, + alias=alias, + display_name=tool.name, + kind="tool", + ) document.note_explicit_target(section) title_node = nodes.title("", "") @@ -303,9 +409,6 @@ def _arg_table( """Build a Parameters/Arguments section for prompt or template args.""" if not args: return [] - from sphinx_autodoc_fastmcp._parsing import make_para, make_table, parse_rst_inline - from sphinx_autodoc_typehints_gp import build_annotation_display_paragraph - headers = ["Argument", "Type", "Required", "Description"] rows: list[list[str | nodes.Node]] = [] for arg in args: @@ -337,9 +440,6 @@ def _arg_table( ] -import typing as t # noqa: E402 # used by helpers above & below - - class FastMCPPromptDirective(SphinxDirective): """Autodocument one MCP prompt: section + card body.""" @@ -350,8 +450,6 @@ class FastMCPPromptDirective(SphinxDirective): def run(self) -> list[nodes.Node]: """Build section with title + description for one prompt.""" - from sphinx_autodoc_fastmcp._parsing import first_paragraph, parse_rst_inline - arg = self.arguments[0] prompt_name = arg.split(".")[-1] if "." in arg else arg prompts: dict[str, PromptInfo] = getattr(self.env, "fastmcp_prompts", {}) @@ -366,7 +464,7 @@ def run(self) -> list[nodes.Node]: ] document = self.state.document - section_id = prompt.name.replace("_", "-") + section_id, _ = _component_ids("prompt", prompt.name) section = nodes.section() section["ids"].append(section_id) section["classes"].extend((_CSS.PROMPT_SECTION, API.CARD_SHELL)) @@ -435,6 +533,7 @@ def run(self) -> list[nodes.Node]: def _build_resource_card( *, + env: BuildEnvironment, state: t.Any, lineno: int, signature_text: str, @@ -446,13 +545,12 @@ def _build_resource_card( entry_class: str, signature_class: str, profile_name: str, - extra_facts: list[tuple[str, nodes.Node]] | None = None, - section_id: str | None = None, - document: t.Any = None, + section_id: str, + display_name: str, + document: t.Any, + permalink_title: str = "Link to this resource", ) -> nodes.Node: """Shared card builder for resources & resource templates.""" - from sphinx_autodoc_fastmcp._parsing import first_paragraph, parse_rst_inline - content_nodes: list[nodes.Node] = [] body = description or first_paragraph(docstring) if body: @@ -464,59 +562,38 @@ def _build_resource_card( ), ) - facts: list[ApiFactRow] = [] if mime_type: - facts.append(ApiFactRow("MIME type", nodes.literal("", mime_type))) - for label, node in extra_facts or (): - facts.append(ApiFactRow(label, node)) - if facts: content_nodes.append( - build_api_facts_section(facts, classes=(_CSS.BODY_SECTION,)), - ) - - permalink: nodes.Node | None = None - if section_id and document is not None: - section = nodes.section() - section["ids"].append(section_id) - section["classes"].extend((shell_class, API.CARD_SHELL)) - _register_section_label( - state.document.settings.env, - section_id, - section_id.replace("-", "_"), + build_api_facts_section( + [ApiFactRow("MIME type", nodes.literal("", mime_type))], + classes=(_CSS.BODY_SECTION,), + ), ) - document.note_explicit_target(section) - title_node = nodes.title("", "") - title_node["classes"].append(_CSS.SECTION_TITLE_HIDDEN) - title_node += nodes.literal("", section_id.replace("-", "_")) - section += title_node + section = nodes.section() + section["ids"].append(section_id) + section["classes"].extend((shell_class, API.CARD_SHELL)) + _register_section_label(env, section_id, display_name) + document.note_explicit_target(section) - link = api_permalink(href=f"#{section_id}", title="Link to this resource") - link["classes"] = ["headerlink", API.LINK] - permalink = link + title_node = nodes.title("", "") + title_node["classes"].append(_CSS.SECTION_TITLE_HIDDEN) + title_node += nodes.literal("", display_name) + section += title_node - section += build_api_card_entry( - profile_class=API.profile(profile_name), - signature_children=(nodes.literal("", signature_text),), - content_children=tuple(content_nodes), - badge_group=badge_group, - permalink=permalink, - entry_classes=(entry_class,), - signature_classes=(signature_class,), - ) - return section + link = api_permalink(href=f"#{section_id}", title=permalink_title) + link["classes"] = ["headerlink", API.LINK] - shell = nodes.container("", classes=[shell_class, API.CARD_SHELL]) - shell += build_api_card_entry( + section += build_api_card_entry( profile_class=API.profile(profile_name), signature_children=(nodes.literal("", signature_text),), content_children=tuple(content_nodes), badge_group=badge_group, - permalink=None, + permalink=link, entry_classes=(entry_class,), signature_classes=(signature_class,), ) - return shell + return section class FastMCPResourceDirective(SphinxDirective): @@ -530,9 +607,11 @@ class FastMCPResourceDirective(SphinxDirective): def run(self) -> list[nodes.Node]: """Build section card for a resource.""" arg = self.arguments[0] - name = arg.split(".")[-1] if "." in arg else arg resources: dict[str, ResourceInfo] = getattr(self.env, "fastmcp_resources", {}) - res = resources.get(name) + names: dict[str, str] = getattr(self.env, "fastmcp_resource_names", {}) + # Try literal URI first, then friendly-name lookup via the name index. + name = arg.split(".")[-1] if "." in arg else arg + res = resources.get(arg) or resources.get(names.get(name, "")) if res is None: return [ self.state.document.reporter.warning( @@ -543,6 +622,7 @@ def run(self) -> list[nodes.Node]: ] return [ _build_resource_card( + env=self.env, state=self.state, lineno=self.lineno, signature_text=res.uri, @@ -558,7 +638,8 @@ def run(self) -> list[nodes.Node]: entry_class=_CSS.RESOURCE_ENTRY, signature_class=_CSS.RESOURCE_SIGNATURE, profile_name="fastmcp-resource", - section_id=res.name.replace("_", "-"), + section_id=_component_ids("resource", res.name)[0], + display_name=res.name, document=self.state.document, ), ] @@ -575,13 +656,15 @@ class FastMCPResourceTemplateDirective(SphinxDirective): def run(self) -> list[nodes.Node]: """Build section card for a resource template.""" arg = self.arguments[0] - name = arg.split(".")[-1] if "." in arg else arg templates: dict[str, ResourceTemplateInfo] = getattr( self.env, "fastmcp_resource_templates", {}, ) - tpl = templates.get(name) + names: dict[str, str] = getattr(self.env, "fastmcp_resource_template_names", {}) + # Try literal URI template first, then friendly-name lookup. + name = arg.split(".")[-1] if "." in arg else arg + tpl = templates.get(arg) or templates.get(names.get(name, "")) if tpl is None: return [ self.state.document.reporter.warning( @@ -591,6 +674,7 @@ def run(self) -> list[nodes.Node]: ), ] card = _build_resource_card( + env=self.env, state=self.state, lineno=self.lineno, signature_text=tpl.uri_template, @@ -606,8 +690,10 @@ def run(self) -> list[nodes.Node]: entry_class=_CSS.RESOURCE_ENTRY, signature_class=_CSS.RESOURCE_SIGNATURE, profile_name="fastmcp-resource-template", - section_id=tpl.name.replace("_", "-"), + section_id=_component_ids("resource-template", tpl.name)[0], + display_name=tpl.name, document=self.state.document, + permalink_title="Link to this resource template", ) result: list[nodes.Node] = [card] if tpl.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 d66f7ff8..44c01673 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py @@ -66,22 +66,30 @@ def collect_tool_section_content(app: Sphinx, doctree: nodes.document) -> None: def register_tool_labels(app: Sphinx, doctree: nodes.document) -> None: - """Mirror autosectionlabel for tool sections (``{ref}`tool-id```).""" + """Mirror autosectionlabel for fastmcp card sections (``{ref}````). + + Re-registers labels for every id on each card section (canonical AND + any back-compat aliases), so incremental Sphinx rebuilds — which + purge labels when a doc changes — restore both shapes from the + doctree cache without re-running the directive's parse-time + registration. + """ domain = app.env.domains.standard_domain docname = app.env.docname for section in doctree.findall(nodes.section): if not section["ids"]: continue - section_id = section["ids"][0] - if section.children and isinstance(section[0], nodes.title): - title_node = section[0] - tool_name = "" - for child in title_node.children: - if isinstance(child, nodes.literal): - tool_name = child.astext() - break - if not tool_name: - continue + if not (section.children and isinstance(section[0], nodes.title)): + continue + title_node = section[0] + tool_name = "" + for child in title_node.children: + if isinstance(child, nodes.literal): + tool_name = child.astext() + break + if not tool_name: + continue + for section_id in section["ids"]: domain.anonlabels[section_id] = (docname, section_id) domain.labels[section_id] = (docname, section_id, tool_name) From 8fb342c1ce290fe5276cbe22632188469f440e4b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 19 Apr 2026 07:34:24 -0500 Subject: [PATCH 13/18] sphinx-autodoc-typehints-gp(fix[numpy]): tilde + brackets + scope rubric MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Three small correctness gaps in the Raises/Notes pipeline. (1) Sphinx convention is that a leading ~ in a role target shows only the last component (`: exc:`~mod.Foo`` -> `Foo`); _strip_roles preserved the tilde verbatim, leaking it into rendered :raises: directives. (2) `type_.split(",")` was naive and would mangle parameterised generics like `Dict[str, X]`. (3) The earlier "drop empty Notes rubric" change incidentally suppressed Examples / References / generic stub sections that intentionally render an empty rubric — broader than the commit intent. The misnamed `_filter_invisible_directives` only handled `.. todo: :`, so rename to reflect actual scope. what: - Add `_shorten_role_target` mirroring sphinx.domains.python.PyXRefRole.process_link; wire into _strip_roles via a sub() callback (drops leading ~ and keeps last dotted segment) - Add `_split_top_level(s, sep)` bracket-depth-aware splitter; use in `_fmt_raises` so `Dict[str, X]` survives intact - Rename `_filter_invisible_directives` -> `_filter_todo_directives` (single-directive scope reflected in name + docstring) - Add `suppress_empty: bool = False` on `_fmt_generic`; only `Notes` passes True. Examples / References / generic admonitions regain their rubric for stub bodies - Tests: new RaisesSectionFixture cases (tilde role, bracketed generic); refactor empty-rubric tests into a single GenericSectionEmptyFixture NamedTuple covering Notes (suppressed) and Examples / References (kept) --- .../_numpy_docstring.py | 88 +++++++++-- tests/ext/typehints_gp/test_unit.py | 142 ++++++++++++++---- 2 files changed, 191 insertions(+), 39 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_numpy_docstring.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_numpy_docstring.py index 60799bfd..a809e9ee 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_numpy_docstring.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_numpy_docstring.py @@ -301,29 +301,97 @@ def _indent(lines: list[str], n: int = 4) -> list[str]: return [pad + line for line in lines] +def _shorten_role_target(target: str) -> str: + """Shorten a role target so a leading ``~`` keeps only the last component. + + Mirrors :class:`sphinx.domains.python.PyXRefRole`. + + Examples + -------- + >>> _shorten_role_target("~pkg.mod.MyErr") + 'MyErr' + >>> _shorten_role_target("~MyErr") + 'MyErr' + >>> _shorten_role_target("pkg.mod.MyErr") + 'pkg.mod.MyErr' + """ + if not target.startswith("~"): + return target + target = target[1:] + dot = target.rfind(".") + if dot != -1: + return target[dot + 1 :] + return target + + def _strip_roles(s: str) -> str: """Strip RST inline role markup, returning just the target text. + Honors the leading ``~`` shortener convention used by Sphinx + cross-references. + Examples -------- >>> _strip_roles(":exc:`ValueError`") 'ValueError' >>> _strip_roles(":exc:`exc.Foo`, :exc:`exc.Bar`") 'exc.Foo, exc.Bar' + >>> _strip_roles(":exc:`~pkg.mod.MyErr`") + 'MyErr' >>> _strip_roles("plain") 'plain' """ - return _ROLE_RE.sub(r"\1", s) + return _ROLE_RE.sub(lambda m: _shorten_role_target(m.group(1)), s) + + +def _split_top_level(s: str, sep: str = ",") -> list[str]: + """Split *s* on *sep* at bracket depth 0 only. + Keeps parameterised generics like ``Dict[str, X]`` intact while still + splitting ``exc.A, exc.B``. -def _filter_invisible_directives(lines: list[str]) -> list[str]: + Examples + -------- + >>> _split_top_level("a, b, c") + ['a', ' b', ' c'] + >>> _split_top_level("Dict[str, X], int") + ['Dict[str, X]', ' int'] + >>> _split_top_level("List[Tuple[int, str]]") + ['List[Tuple[int, str]]'] + >>> _split_top_level("") + [''] + """ + parts: list[str] = [] + current: list[str] = [] + depth = 0 + for ch in s: + if ch in "[(": + depth += 1 + current.append(ch) + elif ch in "])": + depth -= 1 + current.append(ch) + elif ch == sep and depth == 0: + parts.append("".join(current)) + current = [] + else: + current.append(ch) + parts.append("".join(current)) + return parts + + +def _filter_todo_directives(lines: list[str]) -> list[str]: """Remove ``.. todo::`` directives and their indented body from *lines*. + Only ``.. todo::`` is removed — other "invisible" directives + (``.. only::``, ``.. ifconfig::``, etc.) pass through verbatim. The + name reflects the actual scope. + Examples -------- - >>> _filter_invisible_directives([".. todo::", "", " assure it works.", "text"]) + >>> _filter_todo_directives([".. todo::", "", " assure it works.", "text"]) ['text'] - >>> _filter_invisible_directives(["keep this", ".. todo::", " body"]) + >>> _filter_todo_directives(["keep this", ".. todo::", " body"]) ['keep this'] """ result: list[str] = [] @@ -654,7 +722,7 @@ def _dispatch(self, section: str) -> list[str]: label = "Example" if section == "example" else "Examples" return self._fmt_generic(label) if section in _NOTE_NAMES: - return self._fmt_generic("Notes") + return self._fmt_generic("Notes", suppress_empty=True) if section in _REFERENCE_NAMES: return self._fmt_generic("References") if section in _SEE_ALSO_NAMES: @@ -713,7 +781,7 @@ def _fmt_raises(self) -> list[str]: type_ = _strip_roles(type_) exc_types = [ part.strip().rstrip(",") - for part in type_.split(",") + for part in _split_top_level(type_) if part.strip().rstrip(",") ] if not exc_types: @@ -772,14 +840,16 @@ def _fmt_methods(self) -> list[str]: lines.append("") return lines - def _fmt_generic(self, label: str) -> list[str]: + def _fmt_generic(self, label: str, suppress_empty: bool = False) -> list[str]: raw = _strip_empty(self._consume_to_next_section()) raw = _dedent(raw) - raw = _strip_empty(_filter_invisible_directives(raw)) + raw = _strip_empty(_filter_todo_directives(raw)) header = f".. rubric:: {label}" if raw: return [header, "", *raw, ""] - return [] + if suppress_empty: + return [] + return [header, ""] def _fmt_admonition(self, directive: str) -> list[str]: raw = _strip_empty(self._consume_to_next_section()) diff --git a/tests/ext/typehints_gp/test_unit.py b/tests/ext/typehints_gp/test_unit.py index 3a9a2e49..9a2f64ed 100644 --- a/tests/ext/typehints_gp/test_unit.py +++ b/tests/ext/typehints_gp/test_unit.py @@ -831,6 +831,28 @@ class RaisesSectionFixture(t.NamedTuple): ":raises exc.InvalidOption:", ], ), + RaisesSectionFixture( + test_id="raises_with_tilde_role", + input_lines=[ + "Summary.", + "", + "Raises", + "------", + ":exc:`~pkg.mod.MyErr`", + ], + expected_in_output=[":raises MyErr:"], + ), + RaisesSectionFixture( + test_id="raises_with_bracketed_generic", + input_lines=[ + "Summary.", + "", + "Raises", + "------", + "Dict[str, MyErr]", + ], + expected_in_output=[":raises Dict[str, MyErr]:"], + ), ] @@ -931,40 +953,100 @@ def test_numpy_generic_section( assert fragment in joined, f"[{test_id}] Missing: {fragment!r}" +class GenericSectionEmptyFixture(t.NamedTuple): + """Fixture for empty/stub generic section behavior.""" + + test_id: str + input_lines: list[str] + rubric: str + should_emit: bool + + +_GENERIC_EMPTY_FIXTURES: list[GenericSectionEmptyFixture] = [ + GenericSectionEmptyFixture( + test_id="notes_empty_content", + input_lines=[ + "Summary.", + "", + "Notes", + "-----", + ], + rubric=".. rubric:: Notes", + should_emit=False, + ), + GenericSectionEmptyFixture( + test_id="notes_only_todo", + input_lines=[ + "Summary.", + "", + "Notes", + "-----", + ".. todo::", + "", + " assure it works.", + ], + rubric=".. rubric:: Notes", + should_emit=False, + ), + GenericSectionEmptyFixture( + test_id="examples_empty_keeps_rubric", + input_lines=[ + "Summary.", + "", + "Examples", + "--------", + ], + rubric=".. rubric:: Examples", + should_emit=True, + ), + GenericSectionEmptyFixture( + test_id="references_empty_keeps_rubric", + input_lines=[ + "Summary.", + "", + "References", + "----------", + ], + rubric=".. rubric:: References", + should_emit=True, + ), + GenericSectionEmptyFixture( + test_id="examples_only_todo_keeps_rubric", + input_lines=[ + "Summary.", + "", + "Examples", + "--------", + ".. todo::", + "", + " write me", + ], + rubric=".. rubric:: Examples", + should_emit=True, + ), +] + + @pytest.mark.parametrize( - ("test_id", "input_lines"), - [ - ( - "notes_empty_content", - [ - "Summary.", - "", - "Notes", - "-----", - ], - ), - ( - "notes_only_todo", - [ - "Summary.", - "", - "Notes", - "-----", - ".. todo::", - "", - " assure it works.", - ], - ), - ], - ids=["notes_empty_content", "notes_only_todo"], + list(GenericSectionEmptyFixture._fields), + _GENERIC_EMPTY_FIXTURES, + ids=[f.test_id for f in _GENERIC_EMPTY_FIXTURES], ) -def test_numpy_generic_section_no_rubric(test_id: str, input_lines: list[str]) -> None: - """Notes section with no visible content produces no rubric node.""" +def test_numpy_generic_section_empty( + test_id: str, + input_lines: list[str], + rubric: str, + should_emit: bool, +) -> None: + """Empty Notes drops its rubric; other generic sections keep theirs.""" result = process_numpy_docstring(input_lines) joined = "\n".join(result) - assert ".. rubric:: Notes" not in joined, ( - f"[{test_id}] Empty Notes rubric should not be emitted" - ) + if should_emit: + assert rubric in joined, f"[{test_id}] Expected rubric {rubric!r} to be emitted" + else: + assert rubric not in joined, ( + f"[{test_id}] Empty rubric {rubric!r} should not be emitted" + ) # --------------------------------------------------------------------------- From 47a060088325d0b3dd3cbf700e89fc41ff7e559e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 19 Apr 2026 07:36:18 -0500 Subject: [PATCH 14/18] sphinx-autodoc-fastmcp(fix[collector]): always invoke register-all hook why: A FastMCP server may register some components at module-import time (decorators) while leaving others to an explicit register_all() / _register_all(). The previous `if not components` gate skipped the hook whenever any component was already present, silently dropping the deferred ones from autodoc. what: - _resolve_server_instance: drop the empty-components gate; invoke the first matching hook (`register_all` or `_register_all`) whenever the resolved object exposes `local_provider` - Update inline comment to document the unconditional invocation - Add two regression tests: hook fires when components are pre-populated; hook is skipped when no `local_provider` attribute exists --- .../src/sphinx_autodoc_fastmcp/_collector.py | 80 +++++++++++++--- tests/ext/fastmcp/test_fastmcp.py | 91 +++++++++++++++++++ 2 files changed, 156 insertions(+), 15 deletions(-) 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 c2e04893..62f6f6e8 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import importlib import inspect import logging @@ -256,28 +257,77 @@ def _resolve_server_instance(dotted: str) -> t.Any | None: dotted, ) return None - # If the instance has no components yet, try to run the server's - # register-all hook so autodoc sees the same surface a live server does. + # Always invoke the server's register-all hook when one is exported. + # Servers that pre-register some components at import time (decorators) + # while leaving others to an explicit register_all() would otherwise + # have those deferred components silently missing from the docs. + # + # FastMCP's LocalProvider defaults to on_duplicate="error", so calling + # register_all() on an already-populated provider raises and would leave + # the server half-populated. Temporarily switch to "ignore" via a + # context manager so re-invocation is idempotent. If the hook still + # raises (real bug, not duplicate), fail closed (return None) — visible + # empty docs beat partially-wrong docs. provider = getattr(obj, "local_provider", None) if provider is not None: - components = getattr(provider, "_components", None) - if not components: - for hook_name in ("register_all", "_register_all"): - hook = getattr(mod, hook_name, None) - if callable(hook): + # Instance-first lookup — FastMCP's canonical MCPMixin.register_all + # is an instance method; module-level register_all is the ad-hoc + # fallback used by simple scripts. + hook = ( + getattr(obj, "register_all", None) + or getattr(obj, "_register_all", None) + or getattr(mod, "register_all", None) + or getattr(mod, "_register_all", None) + ) + if callable(hook): + try: + with _ignore_duplicate_policy(provider): + # MCPMixin.register_all takes the server as a positional + # arg; ad-hoc module-level hooks are zero-arg with the + # server captured in a closure. try: + hook(obj) + except TypeError: hook() - except Exception: # pragma: no cover - defensive - logger.warning( - "sphinx_autodoc_fastmcp: %s.%s() raised", - module_path, - hook_name, - exc_info=True, - ) - break + except Exception: + logger.warning( + "sphinx_autodoc_fastmcp: register-all hook for %s raised; " + "skipping server", + dotted, + exc_info=True, + ) + return None return obj +_MISSING_DUPLICATE_POLICY = object() + + +@contextlib.contextmanager +def _ignore_duplicate_policy(provider: t.Any) -> t.Iterator[None]: + """Temporarily set ``provider._on_duplicate = "ignore"`` and restore on exit. + + FastMCP's LocalProvider raises ``ValueError`` when re-registering a + component under the default ``on_duplicate="error"`` policy. The + autodoc collector needs idempotent re-invocation, but should not + permanently mutate the user's provider configuration. + + Sphinx invokes ``builder-inited`` synchronously in the main process + before any read/write fork (``sphinx/application.py`` ``_init_builder``); + this manager is therefore safe without locking. + """ + original = getattr(provider, "_on_duplicate", _MISSING_DUPLICATE_POLICY) + provider._on_duplicate = "ignore" + try: + yield + finally: + if original is _MISSING_DUPLICATE_POLICY: + with contextlib.suppress(AttributeError): + delattr(provider, "_on_duplicate") + else: + provider._on_duplicate = original + + def _iter_components(server: t.Any) -> t.Iterable[t.Any]: """Yield every FastMCPComponent registered on ``server.local_provider``. diff --git a/tests/ext/fastmcp/test_fastmcp.py b/tests/ext/fastmcp/test_fastmcp.py index 0f33f723..a4bc12cd 100644 --- a/tests/ext/fastmcp/test_fastmcp.py +++ b/tests/ext/fastmcp/test_fastmcp.py @@ -2,11 +2,16 @@ from __future__ import annotations +import logging +import sys +import types import typing as t +import pytest from docutils import nodes from sphinx_autodoc_fastmcp._badges import build_safety_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 ( extract_params, @@ -91,3 +96,89 @@ def test_make_table_minimal() -> None: """make_table builds a table node.""" t = make_table(["A"], [["x"]]) assert isinstance(t, nodes.table) + + +def test_resolve_server_invokes_register_all_even_when_components_present( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Hook fires regardless of pre-registered components. + + Servers may register some components at import time (decorators) while + leaving others to an explicit ``register_all()`` — gating on + ``_components`` being empty would silently drop the deferred ones. + """ + calls: list[str] = [] + + provider = types.SimpleNamespace(_components={"existing": object()}) + server = types.SimpleNamespace(local_provider=provider) + + fake_module = types.ModuleType("fake_fastmcp_server") + fake_module.mcp = server # type: ignore[attr-defined] + fake_module.register_all = lambda: calls.append("register_all") # type: ignore[attr-defined] + + monkeypatch.setitem(sys.modules, "fake_fastmcp_server", fake_module) + + resolved = _resolve_server_instance("fake_fastmcp_server:mcp") + + assert resolved is server + assert calls == ["register_all"] + + +def test_resolve_server_returns_none_when_attr_is_not_fastmcp( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A configured attr that is not a FastMCP instance resolves to ``None``. + + Returning the bare object would cause ``_iter_components`` to silently + yield ``()`` and produce empty docs without any diagnostic. + """ + calls: list[str] = [] + + bare_obj = types.SimpleNamespace() # no local_provider + + fake_module = types.ModuleType("fake_fastmcp_bare") + fake_module.mcp = bare_obj # type: ignore[attr-defined] + fake_module.register_all = lambda: calls.append("register_all") # type: ignore[attr-defined] + + monkeypatch.setitem(sys.modules, "fake_fastmcp_bare", fake_module) + + resolved = _resolve_server_instance("fake_fastmcp_bare:mcp") + + assert resolved is None + assert calls == [] + + +def test_resolve_server_warns_when_attr_is_not_fastmcp( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """The not-a-FastMCP path emits a WARNING-level diagnostic via caplog.records.""" + bare_obj = types.SimpleNamespace() + fake_module = types.ModuleType("fake_fastmcp_bare2") + fake_module.mcp = bare_obj # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "fake_fastmcp_bare2", fake_module) + + with caplog.at_level(logging.WARNING, logger="sphinx_autodoc_fastmcp"): + _resolve_server_instance("fake_fastmcp_bare2:mcp") + + matched = [ + r + for r in caplog.records + if r.name == "sphinx_autodoc_fastmcp._collector" + and "local_provider" in r.getMessage() + ] + assert len(matched) == 1 + assert matched[0].levelno == logging.WARNING + + +def test_resolve_server_returns_none_when_factory_yields_non_fastmcp( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A factory callable returning a non-FastMCP object resolves to ``None``.""" + fake_module = types.ModuleType("fake_fastmcp_factory") + fake_module.mcp = lambda: types.SimpleNamespace() # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "fake_fastmcp_factory", fake_module) + + resolved = _resolve_server_instance("fake_fastmcp_factory:mcp") + + assert resolved is None From 16aa15ebcbe2c953389f5a70fbf0ded284119050 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 19 Apr 2026 07:36:59 -0500 Subject: [PATCH 15/18] sphinx-autodoc-fastmcp(fix[collector]): simplify _strip_schema_note tail-trim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The chain `text[:idx].rstrip().rstrip("\n").strip()` was redundant — the leading rstrip() already removes trailing newlines, and the final strip() then re-stripped both ends. The intent is parity with the no-marker branch's `text.strip()`. what: - Replace the redundant chain with `text[:idx].strip()` - Add a doctest case proving leading whitespace is stripped (parity with the marker-not-found branch) --- .../src/sphinx_autodoc_fastmcp/_collector.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 62f6f6e8..4ed76688 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py @@ -361,11 +361,13 @@ def _strip_schema_note(text: str) -> str: 'Summary.' >>> _strip_schema_note("Summary.\n\nProvide as a JSON string matching the following schema: {}") 'Summary.' + >>> _strip_schema_note(" Summary. \n\nProvide as a JSON string matching the following schema: {}") + 'Summary.' """ idx = text.find(_SCHEMA_NOTE_MARKER) if idx == -1: return text.strip() - return text[:idx].rstrip().rstrip("\n").strip() + return text[:idx].strip() def _prompt_from_component(prompt: t.Any) -> PromptInfo: From 0e902ecacda74ea1bff99834df7af4370873af3d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 19 Apr 2026 07:37:30 -0500 Subject: [PATCH 16/18] sphinx-autodoc-fastmcp(fix[collector]): narrow fastmcp ImportError why: A bare `except Exception` around the local fastmcp.* imports would silently swallow real bugs (e.g. a SyntaxError in a fastmcp submodule, or runtime errors in user code triggered during import). Sphinx's own optional-dependency pattern (sphinx/util/images.py) catches ImportError specifically. what: - Narrow `except Exception` to `except ImportError` for the fastmcp.prompts / resources / template imports inside collect_prompts_and_resources --- .../src/sphinx_autodoc_fastmcp/_collector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4ed76688..b0e51466 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py @@ -573,7 +573,7 @@ def collect_prompts_and_resources(app: Sphinx) -> None: from fastmcp.resources.template import ( ResourceTemplate as _ResourceTemplate, ) - except Exception: # pragma: no cover - defensive + except ImportError: # pragma: no cover - defensive logger.warning( "sphinx_autodoc_fastmcp: could not import fastmcp types", exc_info=True, From 6cd51182f26cbf89508e740baf40e1620148bb22 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 19 Apr 2026 09:18:23 -0500 Subject: [PATCH 17/18] docs(CHANGES): bug-fix bullets for PR #21 review fixups why: Document the user-visible effects of the seven preceding fixups so the unreleased changelog reflects the final shape of the PR's surface area. what: - Six bullets under Bug fixes covering: typed std-domain accessor + display-name fix, unconditional register-all hook, narrowed ImportError catch, tilde role shortener, bracket-aware Raises split, and scoped empty-rubric suppression. --- CHANGES | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGES b/CHANGES index 987be6c9..43e1e91d 100644 --- a/CHANGES +++ b/CHANGES @@ -84,6 +84,32 @@ $ uv add gp-sphinx --prerelease allow ### Bug fixes +- `sphinx-autodoc-fastmcp`: section labels for prompts and resources now + use the typed `env.domains.standard_domain` accessor and route through the + same direct-write pattern as `_transforms.register_tool_labels`. Resource + and resource-template card labels carry the actual component name (e.g. + ``my_resource``) rather than a slugified round-trip, so ``{ref}`` lookups + resolve against the human-readable identifier (#21) +- `sphinx-autodoc-fastmcp`: the FastMCP register-all hook now fires whenever + the resolved server exposes ``local_provider``, regardless of whether + ``_components`` is already populated. Servers that register some components + at module-import time (decorators) and others via an explicit + ``register_all()`` previously dropped the deferred ones from autodoc (#21) +- `sphinx-autodoc-fastmcp`: narrow the ``except Exception`` around the optional + ``fastmcp.*`` imports to ``except ImportError`` so unrelated runtime errors + during fastmcp import propagate instead of producing silently empty docs + (matches Sphinx's own ``sphinx.util.images`` pattern) (#21) +- `sphinx-autodoc-typehints-gp`: ``:exc:`~mod.Foo``` in ``Raises`` sections + now renders as ``Foo`` (last-component shortener), matching Sphinx's + ``PyXRefRole.process_link`` convention (#21) +- `sphinx-autodoc-typehints-gp`: ``Raises`` type fields split on commas only + at bracket depth 0, keeping parameterised generics like ``Dict[str, X]`` + intact (#21) +- `sphinx-autodoc-typehints-gp`: empty ``Notes`` sections drop their rubric + (intentional after ``.. todo::`` filtering); empty ``Examples``, + ``References``, and other generic stub sections regain their rubric for + legitimate stub usage (scoped via a new ``suppress_empty`` flag on + ``_fmt_generic``) (#21) - `sphinx-gp-theme`: SPA nav now scrolls to the target anchor on cross-page fragment links. `document.querySelector(hash)` mis-parses any hash containing `.` (e.g. Python autodoc IDs like From 8834a5ff038ae08b3f2a3d65ef851cbeead28c48 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 19 Apr 2026 09:18:35 -0500 Subject: [PATCH 18/18] test(fastmcp): integration scenario for the four new directives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: PR #21 added {fastmcp-prompt}, {fastmcp-prompt-input}, {fastmcp-resource}, and {fastmcp-resource-template} but had zero end-to-end coverage; the Weave review flagged this as Critical. Lock in the rendered shape so future refactors can't silently regress section IDs, sibling adoption, MIME pills, or {ref} resolution. what: - Add tests/ext/fastmcp/test_directives_integration.py — one module-scoped SharedSphinxResult fixture builds a synthetic doc with all four directives against an injected FastMCP-shaped fake server (no real fastmcp dependency). - Five @pytest.mark.integration tests assert: canonical fastmcp-{kind}-{name} section IDs, MIME pill class on resource cards, prompt argument table contents, parameter table inside the resource-template card section (sibling adoption), and {ref} cross-references resolve with zero "undefined label" warnings. --- .../fastmcp/test_directives_integration.py | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 tests/ext/fastmcp/test_directives_integration.py diff --git a/tests/ext/fastmcp/test_directives_integration.py b/tests/ext/fastmcp/test_directives_integration.py new file mode 100644 index 00000000..aa513b83 --- /dev/null +++ b/tests/ext/fastmcp/test_directives_integration.py @@ -0,0 +1,245 @@ +"""Integration coverage for the four FastMCP MyST directives. + +Builds one synthetic Sphinx project that registers a fake FastMCP-shaped +server (no real ``fastmcp`` dependency) via a tiny extension, renders all +four directives, and asserts the rendered HTML contains: + +* canonical kind-prefixed section IDs (``fastmcp-{kind}-{name}``) +* the resource MIME pill +* the prompt argument table +* the resource-template parameter table inside the card section + (sibling-adoption transform working) +* ``:ref:`` cross-references resolve to the canonical IDs and the build + emits zero ``undefined label`` warnings +""" + +from __future__ import annotations + +import textwrap + +import pytest +from docutils import nodes + +from tests._sphinx_scenarios import ( + SCENARIO_SRCDIR_TOKEN, + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + get_doctree, + read_output, +) + +# A minimal fake-extension that builds in-memory ``PromptInfo`` / +# ``ResourceInfo`` / ``ResourceTemplateInfo`` instances and stuffs them onto +# ``app.env.fastmcp_*`` directly. Bypasses ``_resolve_server_instance`` so +# we don't need a real fastmcp dep — the directives are what we want to +# exercise here. +_FAKE_EXT_SOURCE = textwrap.dedent( + '''\ + """In-memory fastmcp-shaped fixture extension.""" + + from __future__ import annotations + + import typing as t + + from sphinx.application import Sphinx + + from sphinx_autodoc_fastmcp._models import ( + PromptArgInfo, + PromptInfo, + ResourceInfo, + ResourceTemplateInfo, + ) + + + def _populate(app: Sphinx) -> None: + prompts = { + "greet": PromptInfo( + name="greet", + title="Greet", + description="Greet a user.", + docstring="Greet a user.", + tags=("ops",), + arguments=[ + PromptArgInfo( + name="who", + description="Who to greet.", + required=True, + type_str="str", + ), + ], + ) + } + resources = { + "mem://hello": ResourceInfo( + name="hello", + uri="mem://hello", + title="Hello", + description="Static hello blob.", + mime_type="text/markdown", + docstring="Static hello blob.", + tags=("readonly",), + ) + } + templates = { + "mem://user/{id}": ResourceTemplateInfo( + name="user_record", + uri_template="mem://user/{id}", + title="User record", + description="Per-user record.", + mime_type="application/json", + parameters=[ + PromptArgInfo( + name="id", + description="User identifier.", + required=True, + type_str="str", + ), + ], + docstring="Per-user record.", + tags=("readonly",), + ) + } + app.env.fastmcp_prompts = prompts + app.env.fastmcp_resources = resources + app.env.fastmcp_resource_names = {"hello": "mem://hello"} + app.env.fastmcp_resource_templates = templates + app.env.fastmcp_resource_template_names = { + "user_record": "mem://user/{id}", + } + + + def setup(app: Sphinx) -> dict[str, t.Any]: + # Run AFTER sphinx_autodoc_fastmcp's collect_prompts_and_resources + # has cleared the env attributes — connect to builder-inited with + # the priority lever (>500) so we win. + app.connect("builder-inited", _populate, priority=600) + return {"version": "0.1", "parallel_read_safe": True} + ''' +) + +_CONF_PY = textwrap.dedent( + """\ + from __future__ import annotations + import sys + sys.path.insert(0, r"__SCENARIO_SRCDIR__") + + extensions = [ + "myst_parser", + "sphinx_autodoc_fastmcp", + "fastmcp_fixture_ext", + ] + myst_enable_extensions = ["colon_fence"] + fastmcp_tool_modules = [] + """ +) + +_INDEX_MD = textwrap.dedent( + """\ + # FastMCP demo + + See {ref}`fastmcp-prompt-greet`, {ref}`fastmcp-resource-hello`, and + {ref}`fastmcp-resource-template-user-record`. + + ```{fastmcp-prompt} greet + ``` + + ```{fastmcp-prompt-input} greet + ``` + + --- + + ```{fastmcp-resource} hello + ``` + + --- + + ```{fastmcp-resource-template} user_record + ``` + """ +) + + +@pytest.fixture(scope="module") +def fastmcp_directives_html( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build the demo doc once per module.""" + cache_root = tmp_path_factory.mktemp("fastmcp-directives") + scenario = SphinxScenario( + files=( + ScenarioFile("fastmcp_fixture_ext.py", _FAKE_EXT_SOURCE), + ScenarioFile( + "conf.py", + _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), + substitute_srcdir=True, + ), + ScenarioFile("index.md", _INDEX_MD), + ), + ) + return build_shared_sphinx_result( + cache_root, + scenario, + purge_modules=("fastmcp_fixture_ext",), + ) + + +@pytest.mark.integration +def test_card_section_ids_use_canonical_prefix( + fastmcp_directives_html: SharedSphinxResult, +) -> None: + """Each directive emits a section with the canonical ``fastmcp-{kind}-{name}`` id.""" + html = read_output(fastmcp_directives_html, "index.html") + assert 'id="fastmcp-prompt-greet"' in html + assert 'id="fastmcp-resource-hello"' in html + assert 'id="fastmcp-resource-template-user-record"' in html + + +@pytest.mark.integration +def test_resource_mime_pill_renders( + fastmcp_directives_html: SharedSphinxResult, +) -> None: + """Resource cards expose their MIME type via the dedicated pill class.""" + html = read_output(fastmcp_directives_html, "index.html") + assert "text/markdown" in html + assert "gp-sphinx-api-facts" in html + + +@pytest.mark.integration +def test_prompt_argument_table_renders( + fastmcp_directives_html: SharedSphinxResult, +) -> None: + """The prompt argument table emits the parameter name + description.""" + html = read_output(fastmcp_directives_html, "index.html") + assert "who" in html + assert "Who to greet." in html + + +@pytest.mark.integration +def test_resource_template_param_table_inside_card( + fastmcp_directives_html: SharedSphinxResult, +) -> None: + """Sibling-adoption transform pulls the template's param table inside the card section.""" + doctree = get_doctree(fastmcp_directives_html, "index") + sections = [ + s + for s in doctree.findall(nodes.section) + if "fastmcp-resource-template-user-record" in s.get("ids", []) + ] + assert sections, "resource-template card section missing" + section = sections[0] + tables = list(section.findall(nodes.table)) + assert tables, "expected at least one parameter table inside the card section" + + +@pytest.mark.integration +def test_ref_xrefs_resolve_with_no_undefined_labels( + fastmcp_directives_html: SharedSphinxResult, +) -> None: + """All three :ref: targets resolve to the canonical IDs and emit no warnings.""" + html = read_output(fastmcp_directives_html, "index.html") + assert 'href="#fastmcp-prompt-greet"' in html + assert 'href="#fastmcp-resource-hello"' in html + assert 'href="#fastmcp-resource-template-user-record"' in html + assert "undefined label" not in fastmcp_directives_html.warnings