From 42ef3166074b63fe51ddfb7d3001366b2eac7db8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 15 Jun 2026 19:47:23 -0500 Subject: [PATCH 1/9] fastmcp(test[objects-inv]): Lock component std:label registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Resource, prompt, and resource-template directives already register canonical std-domain labels, so they land in objects.inv and resolve via {ref} — but no test guarded it. Issue #56's acceptance criterion #2 (resources reachable via intersphinx as std:label) was unverified, leaving the behavior free to regress silently. what: - Add tests/ext/fastmcp/test_component_linking.py with a synthetic project that documents one prompt, resource, and resource template - Assert each canonical label lands in the built objects.inv as a std:label --- tests/ext/fastmcp/test_component_linking.py | 194 ++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 tests/ext/fastmcp/test_component_linking.py diff --git a/tests/ext/fastmcp/test_component_linking.py b/tests/ext/fastmcp/test_component_linking.py new file mode 100644 index 00000000..7f72a151 --- /dev/null +++ b/tests/ext/fastmcp/test_component_linking.py @@ -0,0 +1,194 @@ +"""Cross-reference linking coverage for FastMCP component directives & roles. + +Locks issue #56's acceptance criteria. Tools/prompts already linked; this +suite proves resources, prompts, and resource templates register std-domain +labels, so they land in ``objects.inv`` as ``std:label`` +(intersphinx-reachable) and resolve via ``{ref}``. +""" + +from __future__ import annotations + +import textwrap +import typing as t + +import pytest +from sphinx.util.inventory import InventoryFile + +from tests._sphinx_scenarios import ( + SCENARIO_SRCDIR_TOKEN, + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, +) + +# In-memory fastmcp-shaped fixture extension: stuffs one prompt, one resource, +# and one resource template onto ``app.env`` (priority >500 so it wins over +# ``collect_prompts_and_resources`` clearing the attributes). No real fastmcp +# dependency — the directives are what we exercise. +_FIXTURE_EXT = textwrap.dedent( + '''\ + """In-memory fastmcp-shaped fixture extension for linking tests.""" + + from __future__ import annotations + + import typing as t + + from sphinx.application import Sphinx + + from sphinx_autodoc_fastmcp._models import ( + PromptInfo, + ResourceInfo, + ResourceTemplateInfo, + ) + + + def _populate(app: Sphinx) -> None: + app.env.fastmcp_prompts = { + "greet": PromptInfo( + name="greet", + title="Greet", + description="Greet a user.", + docstring="Greet a user.", + tags=("ops",), + arguments=[], + ) + } + app.env.fastmcp_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",), + ) + } + app.env.fastmcp_resource_names = {"hello": "mem://hello"} + app.env.fastmcp_resource_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=[], + docstring="Per-user record.", + tags=("readonly",), + ) + } + app.env.fastmcp_resource_template_names = {"user_record": "mem://user/{id}"} + + + def setup(app: Sphinx) -> dict[str, t.Any]: + 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_link_fixture_ext", + ] + myst_enable_extensions = ["colon_fence"] + fastmcp_tool_modules = [] + """ +) + +_DIRECTIVES_MD = textwrap.dedent( + """\ + # FastMCP linking demo + + ```{fastmcp-prompt} greet + ``` + + --- + + ```{fastmcp-resource} hello + ``` + + --- + + ```{fastmcp-resource-template} user_record + ``` + """ +) + + +def _scenario(*files: ScenarioFile) -> SphinxScenario: + """Assemble a scenario with the shared fixture extension + conf.py.""" + return SphinxScenario( + files=( + ScenarioFile("fastmcp_link_fixture_ext.py", _FIXTURE_EXT), + ScenarioFile( + "conf.py", + _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), + substitute_srcdir=True, + ), + *files, + ), + ) + + +def _load_inventory(result: SharedSphinxResult) -> dict[str, dict[str, t.Any]]: + """Parse the built ``objects.inv`` into ``{domain: {name: item}}``.""" + inv_path = result.outdir / "objects.inv" + with inv_path.open("rb") as handle: + return InventoryFile.load(handle, "", lambda base, target: target) + + +@pytest.fixture(scope="module") +def linking_html(tmp_path_factory: pytest.TempPathFactory) -> SharedSphinxResult: + """Build the component-directives demo once per module.""" + cache_root = tmp_path_factory.mktemp("fastmcp-linking") + scenario = _scenario(ScenarioFile("index.md", _DIRECTIVES_MD)) + return build_shared_sphinx_result( + cache_root, + scenario, + purge_modules=("fastmcp_link_fixture_ext",), + ) + + +# --- objects.inv (issue #56 acceptance #2) --------------------------------- + + +class InventoryCase(t.NamedTuple): + """One expected ``std:label`` inventory entry.""" + + test_id: str + label: str + + +_INVENTORY_CASES: list[InventoryCase] = [ + InventoryCase(test_id="prompt", label="fastmcp-prompt-greet"), + InventoryCase(test_id="resource", label="fastmcp-resource-hello"), + InventoryCase( + test_id="resource-template", + label="fastmcp-resource-template-user-record", + ), +] + + +@pytest.mark.integration +@pytest.mark.parametrize( + "case", + _INVENTORY_CASES, + ids=lambda c: c.test_id, +) +def test_component_label_lands_in_objects_inv( + linking_html: SharedSphinxResult, + case: InventoryCase, +) -> None: + """Each component registers a ``std:label`` reachable via intersphinx.""" + inventory = _load_inventory(linking_html) + assert case.label in inventory["std:label"], ( + f"{case.label} missing from objects.inv std:label entries" + ) From c0728eb37eb8d140932baa68960431f61f9ad876 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 15 Jun 2026 19:49:10 -0500 Subject: [PATCH 2/9] fastmcp(directive[no-index]): Support :no-index: on prompt/resource/template why: Only fastmcp-tool honored the standard :no-index: flag. A prompt, resource, or resource template shown on more than one page had no way to opt a secondary appearance out of label registration, so its canonical cross-reference home was nondeterministic (last page read wins). Issue #56 requests this parity. what: - Add :no-index: to option_spec on FastMCPPromptDirective, FastMCPResourceDirective, and FastMCPResourceTemplateDirective - Skip canonical label registration + note_explicit_target when set, marking the section fastmcp_no_index so the doctree-read pass mirrors the skip - Thread no_index through the shared _build_resource_card helper - Cover two-page no-index behavior in test_component_linking.py --- .../src/sphinx_autodoc_fastmcp/_directives.py | 56 +++++++++++-- tests/ext/fastmcp/test_component_linking.py | 79 ++++++++++++++++++- 2 files changed, 125 insertions(+), 10 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 f046104d..92788a8b 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py @@ -488,12 +488,23 @@ def _arg_table( class FastMCPPromptDirective(SphinxDirective): - """Autodocument one MCP prompt: section + card body.""" + """Autodocument one MCP prompt: section + card body. + + Supports the standard Sphinx ``:no-index:`` flag (mirrors + :class:`FastMCPToolDirective`): when set, the card still renders in full + but its canonical section ID is not registered in :class:`StandardDomain` + ``labels`` / ``anonlabels``. Use it when a prompt appears on more than one + page — exactly one invocation should omit ``:no-index:`` so cross-references + have a single canonical home. + """ required_arguments = 1 optional_arguments = 0 has_content = True final_argument_whitespace = False + option_spec: t.ClassVar[dict[str, t.Callable[[str], t.Any]]] = { + "no-index": directives.flag, + } def run(self) -> list[nodes.Node]: """Build section with title + description for one prompt.""" @@ -512,11 +523,17 @@ def run(self) -> list[nodes.Node]: document = self.state.document section_id, _ = _component_ids("prompt", prompt.name) + no_index = "no-index" in self.options 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) + if no_index: + # Marker consumed by ``register_tool_labels`` so the doctree-read + # pass mirrors the directive's skip on incremental rebuilds. + section["fastmcp_no_index"] = True + else: + _register_section_label(self.env, section_id, prompt.name) + document.note_explicit_target(section) title_node = nodes.title("", "") title_node["classes"].append(_CSS.SECTION_TITLE_HIDDEN) @@ -596,6 +613,7 @@ def _build_resource_card( display_name: str, document: t.Any, permalink_title: str = "Link to this resource", + no_index: bool = False, ) -> nodes.Node: """Shared card builder for resources & resource templates.""" content_nodes: list[nodes.Node] = [] @@ -620,8 +638,13 @@ def _build_resource_card( 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) + if no_index: + # Marker consumed by ``register_tool_labels`` so the doctree-read pass + # mirrors the directive's skip on incremental rebuilds. + section["fastmcp_no_index"] = True + else: + _register_section_label(env, section_id, display_name) + document.note_explicit_target(section) title_node = nodes.title("", "") title_node["classes"].append(_CSS.SECTION_TITLE_HIDDEN) @@ -644,12 +667,21 @@ def _build_resource_card( class FastMCPResourceDirective(SphinxDirective): - """Autodocument one MCP resource (fixed URI).""" + """Autodocument one MCP resource (fixed URI). + + Supports the standard Sphinx ``:no-index:`` flag (mirrors + :class:`FastMCPToolDirective`): when set, the card renders but its canonical + section ID is not registered as a cross-reference target, so a resource + shown on more than one page registers its canonical home exactly once. + """ required_arguments = 1 optional_arguments = 0 has_content = True final_argument_whitespace = False + option_spec: t.ClassVar[dict[str, t.Callable[[str], t.Any]]] = { + "no-index": directives.flag, + } def run(self) -> list[nodes.Node]: """Build section card for a resource.""" @@ -688,17 +720,26 @@ def run(self) -> list[nodes.Node]: section_id=_component_ids("resource", res.name)[0], display_name=res.name, document=self.state.document, + no_index="no-index" in self.options, ), ] class FastMCPResourceTemplateDirective(SphinxDirective): - """Autodocument one MCP resource template (parameterised URI).""" + """Autodocument one MCP resource template (parameterised URI). + + Supports the standard Sphinx ``:no-index:`` flag (mirrors + :class:`FastMCPToolDirective`): when set, the card renders but its canonical + section ID is not registered as a cross-reference target. + """ required_arguments = 1 optional_arguments = 0 has_content = True final_argument_whitespace = False + option_spec: t.ClassVar[dict[str, t.Callable[[str], t.Any]]] = { + "no-index": directives.flag, + } def run(self) -> list[nodes.Node]: """Build section card for a resource template.""" @@ -741,6 +782,7 @@ def run(self) -> list[nodes.Node]: display_name=tpl.name, document=self.state.document, permalink_title="Link to this resource template", + no_index="no-index" in self.options, ) result: list[nodes.Node] = [card] if tpl.parameters: diff --git a/tests/ext/fastmcp/test_component_linking.py b/tests/ext/fastmcp/test_component_linking.py index 7f72a151..46de041b 100644 --- a/tests/ext/fastmcp/test_component_linking.py +++ b/tests/ext/fastmcp/test_component_linking.py @@ -1,9 +1,13 @@ """Cross-reference linking coverage for FastMCP component directives & roles. Locks issue #56's acceptance criteria. Tools/prompts already linked; this -suite proves resources, prompts, and resource templates register std-domain -labels, so they land in ``objects.inv`` as ``std:label`` -(intersphinx-reachable) and resolve via ``{ref}``. +suite proves the parity affordances for the non-tool families: + +* resources, prompts, and resource templates register std-domain labels, so + they land in ``objects.inv`` as ``std:label`` (intersphinx-reachable) and + resolve via ``{ref}`` +* ``:no-index:`` suppresses cross-document label registration so a component + shown on more than one page keeps a single canonical home """ from __future__ import annotations @@ -192,3 +196,72 @@ def test_component_label_lands_in_objects_inv( assert case.label in inventory["std:label"], ( f"{case.label} missing from objects.inv std:label entries" ) + + +# --- :no-index: parity (issue #56 request) --------------------------------- + +_NO_INDEX_INDEX_MD = textwrap.dedent( + """\ + # No-index demo + + ```{toctree} + canonical + secondary + ``` + """ +) + +_CANONICAL_MD = textwrap.dedent( + """\ + # Canonical home + + ```{fastmcp-resource} hello + ``` + """ +) + +_SECONDARY_MD = textwrap.dedent( + """\ + # Secondary appearance + + ```{fastmcp-resource} hello + :no-index: + ``` + """ +) + + +@pytest.fixture(scope="module") +def no_index_html(tmp_path_factory: pytest.TempPathFactory) -> SharedSphinxResult: + """Build a resource shown on two pages, the second with ``:no-index:``.""" + cache_root = tmp_path_factory.mktemp("fastmcp-no-index") + scenario = _scenario( + ScenarioFile("index.md", _NO_INDEX_INDEX_MD), + ScenarioFile("canonical.md", _CANONICAL_MD), + ScenarioFile("secondary.md", _SECONDARY_MD), + ) + return build_shared_sphinx_result( + cache_root, + scenario, + purge_modules=("fastmcp_link_fixture_ext",), + ) + + +@pytest.mark.integration +def test_no_index_resource_keeps_single_canonical_home( + no_index_html: SharedSphinxResult, +) -> None: + """``:no-index:`` binds the canonical label to the page that omits it.""" + inventory = _load_inventory(no_index_html) + item = inventory["std:label"]["fastmcp-resource-hello"] + assert item.uri.startswith("canonical"), ( + f"canonical label should point at canonical page, got {item.uri!r}" + ) + + +@pytest.mark.integration +def test_no_index_resource_emits_no_duplicate_label_warning( + no_index_html: SharedSphinxResult, +) -> None: + """The opted-out page registers no competing label, so no duplicate warning.""" + assert "duplicate label" not in no_index_html.warnings.lower() From a814590aeab4e00bf4c6b207e7a788f8135028ac Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 15 Jun 2026 19:51:06 -0500 Subject: [PATCH 3/9] fastmcp(roles[component]): Add {resource}/{prompt} cross-reference roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Tools had {tool}/{toolref} inline cross-reference roles, but resources, prompts, and resource templates had none — prose could only link them via the verbose canonical {ref}`fastmcp-resource-`. Issue #56's follow-on asks for the same chip affordance. what: - Add a kind-agnostic _component_ref_placeholder + role factories in _roles.py for {resource}/{resourceref}/{prompt}/{promptref}, targeting the canonical fastmcp-- label (resources/prompts carry no bare-slug alias) - Add resolve_component_refs (doctree-resolved) mirroring resolve_tool_refs without the safety-badge branches; {resource} resolves against both the resource and resource-template id families; unresolved names degrade to a bare literal - Register the four roles and connect the resolver in setup() - Cover role resolution, template fallback, and graceful degradation in tests --- .../src/sphinx_autodoc_fastmcp/__init__.py | 10 +++ .../src/sphinx_autodoc_fastmcp/_roles.py | 49 +++++++++++++++ .../src/sphinx_autodoc_fastmcp/_transforms.py | 61 ++++++++++++++++++- tests/ext/fastmcp/test_component_linking.py | 56 +++++++++++++++++ 4 files changed, 175 insertions(+), 1 deletion(-) 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 c3aeabb3..f85fe62e 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -29,6 +29,10 @@ FastMCPToolSummaryDirective, ) from sphinx_autodoc_fastmcp._roles import ( + _prompt_role, + _promptref_role, + _resource_role, + _resourceref_role, _tool_role, _toolicon_role, _tooliconil_role, @@ -42,6 +46,7 @@ badge_role, collect_tool_section_content, register_tool_labels, + resolve_component_refs, resolve_tool_refs, ) @@ -177,6 +182,7 @@ def _add_static_path(app: Sphinx) -> None: app.connect("doctree-read", collect_tool_section_content) app.connect("doctree-resolved", add_section_badges) app.connect("doctree-resolved", resolve_tool_refs) + app.connect("doctree-resolved", resolve_component_refs) app.add_role("tool", _tool_role) app.add_role("toolref", _toolref_role) @@ -185,6 +191,10 @@ def _add_static_path(app: Sphinx) -> None: app.add_role("tooliconr", _tooliconr_role) app.add_role("tooliconil", _tooliconil_role) app.add_role("tooliconir", _tooliconir_role) + app.add_role("resource", _resource_role) + app.add_role("resourceref", _resourceref_role) + app.add_role("prompt", _prompt_role) + app.add_role("promptref", _promptref_role) app.add_role("badge", badge_role) app.add_directive("fastmcp-tool", FastMCPToolDirective) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py index 40c3e4c4..d911929a 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py @@ -72,3 +72,52 @@ def role_fn( _tooliconr_role = _make_toolicon_role("right") _tooliconil_role = _make_toolicon_role("inline-left") _tooliconir_role = _make_toolicon_role("inline-right") + + +class _component_ref_placeholder(nodes.General, nodes.Inline, nodes.Element): + """Placeholder for prompt/resource cross-refs, resolved at ``doctree-resolved``. + + Unlike tools, resources and prompts carry no bare-slug alias, so the + resolver targets the canonical ``fastmcp--`` id directly. The + ``refkind`` attribute selects the candidate-id family in + :func:`sphinx_autodoc_fastmcp._transforms.resolve_component_refs`. + """ + + +def _make_component_ref_role( + refkind: str, +) -> t.Callable[..., tuple[list[nodes.Node], list[nodes.system_message]]]: + """Create a resource/prompt cross-reference role callable. + + The role renders an inline code literal linked to the component card; it + carries no safety badge (only tools have a safety tier). + """ + + def role_fn( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: object, + options: dict[str, object] | None = None, + content: list[str] | None = None, + ) -> tuple[list[nodes.Node], list[nodes.system_message]]: + display = text.strip() + node = _component_ref_placeholder( + rawtext, + refkind=refkind, + refslug=display.replace("_", "-"), + reftext=display, + ) + return [node], [] + + return role_fn + + +# ``{resource}`` covers both fixed resources and resource templates (the +# resolver tries each id family in turn); ``{resourceref}`` is the explicit +# plain-reference spelling mirroring ``{toolref}``. +_resource_role = _make_component_ref_role("resource") +_resourceref_role = _make_component_ref_role("resource") +_prompt_role = _make_component_ref_role("prompt") +_promptref_role = _make_component_ref_role("prompt") 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 f7e984af..c76aa9ab 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py @@ -11,7 +11,10 @@ from sphinx_autodoc_fastmcp._badges import build_safety_badge from sphinx_autodoc_fastmcp._css import _CSS from sphinx_autodoc_fastmcp._models import ToolInfo -from sphinx_autodoc_fastmcp._roles import _tool_ref_placeholder +from sphinx_autodoc_fastmcp._roles import ( + _component_ref_placeholder, + _tool_ref_placeholder, +) from sphinx_ux_autodoc_layout import API, api_component from sphinx_ux_badges import SAB @@ -213,6 +216,62 @@ def resolve_tool_refs( node.replace_self(newnode) +_COMPONENT_REF_CANDIDATES: dict[str, tuple[str, ...]] = { + "resource": ("fastmcp-resource-{slug}", "fastmcp-resource-template-{slug}"), + "prompt": ("fastmcp-prompt-{slug}",), +} + + +def resolve_component_refs( + app: Sphinx, + doctree: nodes.document, + fromdocname: str, +) -> None: + """Resolve ``:resource:`` / ``:resourceref:`` / ``:prompt:`` / ``:promptref:``. + + Mirrors :func:`resolve_tool_refs` without the safety-badge branches: + resources and prompts have no safety tier, so each placeholder becomes a + plain inline reference (``reference`` wrapping ``literal``). ``{resource}`` + resolves against both the resource and resource-template id families so one + role spelling covers both. An unresolved target degrades to a bare literal. + """ + domain = app.env.domains.standard_domain + builder = app.builder + + for node in list(doctree.findall(_component_ref_placeholder)): + kind = node.get("refkind", "") + slug = node.get("refslug", "") + display = node.get("reftext", "") or slug + + label_info = None + for template in _COMPONENT_REF_CANDIDATES.get(kind, ()): + label_info = domain.labels.get(template.format(slug=slug)) + if label_info is not None: + break + + if label_info is None: + node.replace_self(nodes.literal("", display)) + continue + + todocname, labelid, _title = label_info + newnode = nodes.reference("", "", internal=True) + try: + newnode["refuri"] = builder.get_relative_uri(fromdocname, todocname) + if labelid: + newnode["refuri"] += "#" + labelid + except Exception: + logger.warning( + "sphinx_autodoc_fastmcp: failed to resolve URI for %s -> %s", + fromdocname, + todocname, + ) + newnode["refuri"] = "#" + labelid + newnode["classes"].append("reference") + newnode["classes"].append("internal") + newnode += nodes.literal("", display) + node.replace_self(newnode) + + def badge_role( name: str, rawtext: str, diff --git a/tests/ext/fastmcp/test_component_linking.py b/tests/ext/fastmcp/test_component_linking.py index 46de041b..33493afd 100644 --- a/tests/ext/fastmcp/test_component_linking.py +++ b/tests/ext/fastmcp/test_component_linking.py @@ -6,6 +6,10 @@ * resources, prompts, and resource templates register std-domain labels, so they land in ``objects.inv`` as ``std:label`` (intersphinx-reachable) and resolve via ``{ref}`` +* the ``{resource}`` / ``{resourceref}`` / ``{prompt}`` / ``{promptref}`` role + families resolve to the canonical anchors, with ``{resource}`` covering both + fixed resources and resource templates and unknown names degrading to a bare + literal * ``:no-index:`` suppresses cross-document label registration so a component shown on more than one page keeps a single canonical home """ @@ -24,6 +28,7 @@ SharedSphinxResult, SphinxScenario, build_shared_sphinx_result, + read_output, ) # In-memory fastmcp-shaped fixture extension: stuffs one prompt, one resource, @@ -111,6 +116,9 @@ def setup(app: Sphinx) -> dict[str, t.Any]: """\ # FastMCP linking demo + Roles: {prompt}`greet`, {promptref}`greet`, {resource}`hello`, + {resourceref}`hello`, {resource}`user_record`, {resource}`does_not_exist`. + ```{fastmcp-prompt} greet ``` @@ -198,6 +206,54 @@ def test_component_label_lands_in_objects_inv( ) +# --- role families (issue #56 optional follow-on) -------------------------- + + +class RoleCase(t.NamedTuple): + """One inline role usage and the canonical anchor it must resolve to.""" + + test_id: str + href_target: str + + +_ROLE_CASES: list[RoleCase] = [ + RoleCase(test_id="prompt", href_target="fastmcp-prompt-greet"), + RoleCase(test_id="promptref", href_target="fastmcp-prompt-greet"), + RoleCase(test_id="resource", href_target="fastmcp-resource-hello"), + RoleCase(test_id="resourceref", href_target="fastmcp-resource-hello"), + RoleCase( + test_id="resource-covers-template", + href_target="fastmcp-resource-template-user-record", + ), +] + + +@pytest.mark.integration +@pytest.mark.parametrize( + "case", + _ROLE_CASES, + ids=lambda c: c.test_id, +) +def test_component_role_resolves_to_canonical_anchor( + linking_html: SharedSphinxResult, + case: RoleCase, +) -> None: + """``{resource}`` / ``{prompt}`` role families link to the card anchor.""" + html = read_output(linking_html, "index.html") + assert f'href="#{case.href_target}"' in html + + +@pytest.mark.integration +def test_unknown_component_role_degrades_to_literal( + linking_html: SharedSphinxResult, +) -> None: + """An unresolved component name renders a bare literal, not a dangling link.""" + html = read_output(linking_html, "index.html") + assert "does_not_exist" in html + assert 'href="#fastmcp-resource-does-not-exist"' not in html + assert "undefined label" not in linking_html.warnings + + # --- :no-index: parity (issue #56 request) --------------------------------- _NO_INDEX_INDEX_MD = textwrap.dedent( From e7d299d1e2f93b822286b13ea4dc140370d536de Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 15 Jun 2026 19:52:53 -0500 Subject: [PATCH 4/9] argparse(directive[no-index]): Support :no-index: to suppress xref targets why: The argparse directive was the only auto-directive in the workspace without :no-index:. A parser documented on more than one page registered its options, program, and subcommands on every appearance, polluting objects.inv and the per-domain index with duplicate cross-reference targets and giving the parser no single canonical xref home. what: - Add :no-index: to ArgparseDirective.option_spec; set RenderConfig.register_xref_targets from it in _build_render_config - Gate _register_argument / _register_program / _register_subcommand and the implicit section names on register_xref_targets, keeping HTML anchors intact - Cover objects.inv suppression and intact rendering in a new integration test --- .../src/sphinx_autodoc_argparse/directive.py | 7 + .../src/sphinx_autodoc_argparse/renderer.py | 22 ++- .../test_no_index_integration.py | 172 ++++++++++++++++++ 3 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 tests/ext/autodoc_argparse/test_no_index_integration.py diff --git a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/directive.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/directive.py index 172b3b4a..51951276 100644 --- a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/directive.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/directive.py @@ -43,6 +43,10 @@ class ArgparseDirective(SphinxDirective): Override the program name (optional). :path: Navigate to a specific subparser by path (e.g., "sync pull"). + :no-index: + Render the parser without registering cross-reference targets + (flag). Use on secondary appearances so a parser documented on + more than one page keeps a single canonical xref home. :no-defaults: Don't show default values (flag). :no-description: @@ -73,6 +77,7 @@ class ArgparseDirective(SphinxDirective): "func": directives.unchanged_required, "prog": directives.unchanged, "path": directives.unchanged, + "no-index": directives.flag, "no-defaults": directives.flag, "no-description": directives.flag, "no-epilog": directives.flag, @@ -200,6 +205,8 @@ def _build_render_config(self) -> RenderConfig: config.show_choices = False if "no-types" in self.options: config.show_types = False + if "no-index" in self.options: + config.register_xref_targets = False return config diff --git a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/renderer.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/renderer.py index d4724cfe..d66d5e09 100644 --- a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/renderer.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/renderer.py @@ -54,6 +54,12 @@ class RenderConfig: show_defaults: bool = True show_choices: bool = True show_types: bool = True + # When False (set by the directive's ``:no-index:`` flag) the render still + # emits full markup and per-section HTML anchors, but registers no + # cross-reference targets: no ``argparse:*`` / ``std:cmdoption`` inventory + # entries and no implicit section labels. Lets a parser appear on more than + # one page with a single canonical xref home. + register_xref_targets: bool = True @classmethod def from_sphinx_config(cls, config: Config) -> RenderConfig: @@ -194,7 +200,7 @@ def _register_argument( >>> r = ArgparseRenderer() >>> r._register_argument("myapp", ["--verbose"], "verbose") # no-op without env """ - if self.env is None or not names: + if self.env is None or not names or not self.config.register_xref_targets: return std_domain = self.env.domains.standard_domain std_program = prog_name.replace(" ", "-") if prog_name else None @@ -233,7 +239,7 @@ def _register_program(self, prog_name: str, anchor_id: str) -> None: >>> r = ArgparseRenderer() >>> r._register_program("myapp", "argparse-myapp") # no-op without env """ - if self.env is None or not prog_name: + if self.env is None or not prog_name or not self.config.register_xref_targets: return argparse_domain = self.env.domains["argparse"] argparse_domain.note_program( # type: ignore[attr-defined] @@ -265,7 +271,7 @@ def _register_subcommand( >>> r = ArgparseRenderer() >>> r._register_subcommand("myapp", "sync", "argparse-myapp-sync") """ - if self.env is None or not name: + if self.env is None or not name or not self.config.register_xref_targets: return argparse_domain = self.env.domains["argparse"] argparse_domain.note_subcommand( # type: ignore[attr-defined] @@ -443,7 +449,9 @@ def render_usage_section( section["ids"] = [section_id] # Scope section name by id_prefix so multi-page docs don't collide # on the implicit "usage" target docutils creates from section["names"]. - section["names"] = [nodes.fully_normalize_name(section_id)] + # Omitted under :no-index: so the opted-out render leaks no label. + if self.config.register_xref_targets: + section["names"] = [nodes.fully_normalize_name(section_id)] section += nodes.title("Usage", "Usage") usage_node = argparse_usage() @@ -518,10 +526,12 @@ def render_group_section( # Create section wrapper for TOC discovery. Scope section name by # id_prefix so multi-page docs don't collide on the implicit target - # docutils creates from section["names"]. + # docutils creates from section["names"]. Omitted under :no-index: so + # the opted-out render leaks no label. section = nodes.section() section["ids"] = [section_id] - section["names"] = [nodes.fully_normalize_name(section_id)] + if self.config.register_xref_targets: + section["names"] = [nodes.fully_normalize_name(section_id)] # Add title for TOC - Sphinx's TocTreeCollector looks for this section += nodes.title(title, title) diff --git a/tests/ext/autodoc_argparse/test_no_index_integration.py b/tests/ext/autodoc_argparse/test_no_index_integration.py new file mode 100644 index 00000000..14f946a2 --- /dev/null +++ b/tests/ext/autodoc_argparse/test_no_index_integration.py @@ -0,0 +1,172 @@ +"""Integration test for the ``:no-index:`` flag on the argparse directive. + +Builds a synthetic Sphinx project that renders one parser with ``:no-index:`` +and verifies the card still renders (HTML anchors intact) while registering no +cross-reference targets: no ``argparse:*`` / ``std:cmdoption`` ``objects.inv`` +entries, no ``std`` domain ``progoptions``, and no implicit section labels. This +lets a parser appear on more than one page with a single canonical xref home. +""" + +from __future__ import annotations + +import io +import pathlib +import re +import sys +import textwrap +import typing as t + +import pytest +from sphinx.util.inventory import InventoryFile + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + + +_PARSER_MOD = textwrap.dedent( + """\ + from __future__ import annotations + + import argparse + + + def create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="myapp") + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose") + parser.add_argument("filename", help="Input file") + + sub = parser.add_subparsers(dest="command") + sync = sub.add_parser("sync", help="Synchronise") + sync.add_argument("--force", action="store_true", help="Force sync") + + return parser + """, +) + + +_CONF_PY = textwrap.dedent( + """\ + import sys + sys.path.insert(0, r"{srcdir}") + + project = "argparse_no_index" + extensions = [ + "myst_parser", + "sphinx_autodoc_argparse", + ] + master_doc = "index" + exclude_patterns = ["_build"] + html_theme = "alabaster" + source_suffix = {{".md": "markdown"}} + """, +) + + +_INDEX_MD = textwrap.dedent( + """\ + # CLI Reference + + ```{eval-rst} + .. argparse:: + :module: myparser + :func: create_parser + :prog: myapp + :no-index: + ``` + """, +) + + +_ANSI = re.compile(r"\x1b\[[0-9;]*m") + + +class _Result(t.NamedTuple): + app: Sphinx + warnings: str + outdir: pathlib.Path + + +def _purge_parser_module() -> None: + for key in list(sys.modules): + if key == "myparser": + del sys.modules[key] + + +def _build(tmp_path: pathlib.Path) -> _Result: + from sphinx.application import Sphinx + + srcdir = tmp_path / "src" + outdir = tmp_path / "out" + doctreedir = tmp_path / ".doctrees" + srcdir.mkdir() + outdir.mkdir() + doctreedir.mkdir() + + (srcdir / "myparser.py").write_text(_PARSER_MOD, encoding="utf-8") + (srcdir / "conf.py").write_text( + _CONF_PY.format(srcdir=str(srcdir)), + encoding="utf-8", + ) + (srcdir / "index.md").write_text(_INDEX_MD, encoding="utf-8") + + status_buf = io.StringIO() + warning_buf = io.StringIO() + + _purge_parser_module() + + app = Sphinx( + srcdir=str(srcdir), + confdir=str(srcdir), + outdir=str(outdir), + doctreedir=str(doctreedir), + buildername="html", + freshenv=True, + status=status_buf, + warning=warning_buf, + ) + app.build() + + warnings = _ANSI.sub("", warning_buf.getvalue()) + return _Result(app=app, warnings=warnings, outdir=outdir) + + +def _load_inventory(outdir: pathlib.Path) -> dict[str, dict[str, t.Any]]: + inv_path = outdir / "objects.inv" + with inv_path.open("rb") as handle: + return InventoryFile.load(handle, "", lambda base, target: target) + + +@pytest.fixture(scope="module") +def no_index_result(tmp_path_factory: pytest.TempPathFactory) -> _Result: + """Build a Sphinx project with a ``:no-index:`` argparse directive.""" + return _build(tmp_path_factory.mktemp("argparse-no-index")) + + +@pytest.mark.integration +def test_no_index_registers_no_argparse_domain_entries( + no_index_result: _Result, +) -> None: + """No ``argparse:*`` cross-reference targets land in objects.inv.""" + inventory = _load_inventory(no_index_result.outdir) + argparse_domains = [ + domain for domain in inventory if domain.startswith("argparse:") + ] + assert argparse_domains == [] + + +@pytest.mark.integration +def test_no_index_registers_no_std_cmdoption(no_index_result: _Result) -> None: + """No ``std:cmdoption`` entries and an empty std-domain progoptions table.""" + inventory = _load_inventory(no_index_result.outdir) + assert inventory.get("std:cmdoption", {}) == {} + + std_domain = no_index_result.app.env.domains.standard_domain + assert dict(std_domain.progoptions) == {} + + +@pytest.mark.integration +def test_no_index_still_renders_the_card(no_index_result: _Result) -> None: + """The parser still renders with per-section HTML anchors.""" + index_html = (no_index_result.outdir / "index.html").read_text(encoding="utf-8") + assert "Usage" in index_html + assert 'id="usage"' in index_html From 1d28288235c44ab3ea1f6ef0bb060da8c5ef05b9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 15 Jun 2026 19:54:40 -0500 Subject: [PATCH 5/9] fastmcp(docs[tutorial]): Document component roles and :no-index: why: The tutorial showed only the {tool}/{toolref} inline roles and never mentioned that prompts, resources, and templates support :no-index:. Readers had no pointer to the new cross-reference affordances. what: - Show {resource}/{resourceref}/{prompt}/{promptref} inline-role usage, noting {resource} resolves both fixed resources and resource templates - Document the :no-index: flag for multi-page prompt/resource/template cards --- .../sphinx-autodoc-fastmcp/tutorial.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/packages/sphinx-autodoc-fastmcp/tutorial.md b/docs/packages/sphinx-autodoc-fastmcp/tutorial.md index 3b5e0421..b3fbad34 100644 --- a/docs/packages/sphinx-autodoc-fastmcp/tutorial.md +++ b/docs/packages/sphinx-autodoc-fastmcp/tutorial.md @@ -35,6 +35,16 @@ Use {tool}`list_sessions` for a linked badge, or {toolref}`delete_session` for a plain inline reference. ```` +Prompts and resources have the same affordance (without a safety badge, which +only tools carry). `{resource}` resolves a fixed resource or a resource +template by name; `{prompt}` resolves a prompt: + +````myst +See the {resource}`status` resource, the {resource}`events_by_day` template, +and the {prompt}`greet` prompt. The `{resourceref}` / `{promptref}` spellings +are aliases mirroring `{toolref}`. +```` + ### Prompts and resources After setting `fastmcp_server_module`, four MyST directives become available @@ -59,6 +69,17 @@ Resources and resource templates accept either the friendly component name distinct resources share a name, autodoc keeps the first registration and emits a warning — disambiguate by URI. +Each of these directives accepts the standard `:no-index:` flag. When a +prompt, resource, or resource template is shown on more than one page, add +`:no-index:` to every appearance except the canonical one so the card still +renders everywhere but registers its cross-reference target exactly once: + +````myst +```{fastmcp-resource} my_resource +:no-index: +``` +```` + ### `:ref:` cross-reference IDs Section IDs follow `fastmcp-{kind}-{name}` (canonical): From f4f4fccb7170afeb65ff2ba7093076b2b070c8b7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 15 Jun 2026 20:16:31 -0500 Subject: [PATCH 6/9] fastmcp(docs[roles]): Correct _component_ref_placeholder resolver docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The docstring said the resolver "targets the canonical fastmcp-- id directly" — accurate for prompts but wrong for resources, where the resolver tries fastmcp-resource- then fastmcp-resource-template-. what: - Describe the per-kind candidate families: prompt -> single canonical id; resource -> resource then resource-template, so one spelling links either --- .../src/sphinx_autodoc_fastmcp/_roles.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py index d911929a..2e236143 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py @@ -77,10 +77,12 @@ def role_fn( class _component_ref_placeholder(nodes.General, nodes.Inline, nodes.Element): """Placeholder for prompt/resource cross-refs, resolved at ``doctree-resolved``. - Unlike tools, resources and prompts carry no bare-slug alias, so the - resolver targets the canonical ``fastmcp--`` id directly. The - ``refkind`` attribute selects the candidate-id family in - :func:`sphinx_autodoc_fastmcp._transforms.resolve_component_refs`. + Unlike tools, resources and prompts carry no bare-slug alias. The + ``refkind`` attribute selects the canonical-id family that + :func:`sphinx_autodoc_fastmcp._transforms.resolve_component_refs` looks up: + ``prompt`` targets the single ``fastmcp-prompt-`` id, while + ``resource`` tries ``fastmcp-resource-`` then + ``fastmcp-resource-template-`` so one spelling links either. """ From 05a46155e992e4856d524f374e610a64c1bae5c1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 15 Jun 2026 20:18:08 -0500 Subject: [PATCH 7/9] argparse(test[no-index]): Build the no-index test via the shared harness why: The :no-index: test hand-rolled a Sphinx() build and a private _Result NamedTuple, while the sibling component-linking test added in the same work uses tests/_sphinx_scenarios.py. Use the shared harness so both follow one pattern. what: - Replace _build/_Result/_purge_parser_module with SphinxScenario + build_shared_sphinx_result (module-scoped, purge_modules=("myparser",)) - Read objects.inv from result.outdir and the rendered card via read_output; assertions (no argparse:* / std:cmdoption, empty progoptions, card renders) are unchanged --- .../test_no_index_integration.py | 124 +++++++----------- 1 file changed, 44 insertions(+), 80 deletions(-) diff --git a/tests/ext/autodoc_argparse/test_no_index_integration.py b/tests/ext/autodoc_argparse/test_no_index_integration.py index 14f946a2..12afa783 100644 --- a/tests/ext/autodoc_argparse/test_no_index_integration.py +++ b/tests/ext/autodoc_argparse/test_no_index_integration.py @@ -9,19 +9,20 @@ from __future__ import annotations -import io -import pathlib -import re -import sys import textwrap import typing as t import pytest from sphinx.util.inventory import InventoryFile -if t.TYPE_CHECKING: - from sphinx.application import Sphinx - +from tests._sphinx_scenarios import ( + SCENARIO_SRCDIR_TOKEN, + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + read_output, +) _PARSER_MOD = textwrap.dedent( """\ @@ -40,28 +41,23 @@ def create_parser() -> argparse.ArgumentParser: sync.add_argument("--force", action="store_true", help="Force sync") return parser - """, + """ ) - _CONF_PY = textwrap.dedent( """\ + from __future__ import annotations import sys - sys.path.insert(0, r"{srcdir}") + sys.path.insert(0, r"__SCENARIO_SRCDIR__") project = "argparse_no_index" extensions = [ "myst_parser", "sphinx_autodoc_argparse", ] - master_doc = "index" - exclude_patterns = ["_build"] - html_theme = "alabaster" - source_suffix = {{".md": "markdown"}} - """, + """ ) - _INDEX_MD = textwrap.dedent( """\ # CLI Reference @@ -73,81 +69,45 @@ def create_parser() -> argparse.ArgumentParser: :prog: myapp :no-index: ``` - """, + """ ) -_ANSI = re.compile(r"\x1b\[[0-9;]*m") - - -class _Result(t.NamedTuple): - app: Sphinx - warnings: str - outdir: pathlib.Path - - -def _purge_parser_module() -> None: - for key in list(sys.modules): - if key == "myparser": - del sys.modules[key] - - -def _build(tmp_path: pathlib.Path) -> _Result: - from sphinx.application import Sphinx - - srcdir = tmp_path / "src" - outdir = tmp_path / "out" - doctreedir = tmp_path / ".doctrees" - srcdir.mkdir() - outdir.mkdir() - doctreedir.mkdir() - - (srcdir / "myparser.py").write_text(_PARSER_MOD, encoding="utf-8") - (srcdir / "conf.py").write_text( - _CONF_PY.format(srcdir=str(srcdir)), - encoding="utf-8", - ) - (srcdir / "index.md").write_text(_INDEX_MD, encoding="utf-8") - - status_buf = io.StringIO() - warning_buf = io.StringIO() - - _purge_parser_module() - - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - freshenv=True, - status=status_buf, - warning=warning_buf, - ) - app.build() - - warnings = _ANSI.sub("", warning_buf.getvalue()) - return _Result(app=app, warnings=warnings, outdir=outdir) - - -def _load_inventory(outdir: pathlib.Path) -> dict[str, dict[str, t.Any]]: - inv_path = outdir / "objects.inv" +def _load_inventory(result: SharedSphinxResult) -> dict[str, dict[str, t.Any]]: + """Parse the built ``objects.inv`` into ``{domain: {name: item}}``.""" + inv_path = result.outdir / "objects.inv" with inv_path.open("rb") as handle: return InventoryFile.load(handle, "", lambda base, target: target) @pytest.fixture(scope="module") -def no_index_result(tmp_path_factory: pytest.TempPathFactory) -> _Result: +def no_index_result(tmp_path_factory: pytest.TempPathFactory) -> SharedSphinxResult: """Build a Sphinx project with a ``:no-index:`` argparse directive.""" - return _build(tmp_path_factory.mktemp("argparse-no-index")) + cache_root = tmp_path_factory.mktemp("argparse-no-index") + scenario = SphinxScenario( + files=( + ScenarioFile("myparser.py", _PARSER_MOD), + 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=("myparser",), + ) @pytest.mark.integration def test_no_index_registers_no_argparse_domain_entries( - no_index_result: _Result, + no_index_result: SharedSphinxResult, ) -> None: """No ``argparse:*`` cross-reference targets land in objects.inv.""" - inventory = _load_inventory(no_index_result.outdir) + inventory = _load_inventory(no_index_result) argparse_domains = [ domain for domain in inventory if domain.startswith("argparse:") ] @@ -155,9 +115,11 @@ def test_no_index_registers_no_argparse_domain_entries( @pytest.mark.integration -def test_no_index_registers_no_std_cmdoption(no_index_result: _Result) -> None: +def test_no_index_registers_no_std_cmdoption( + no_index_result: SharedSphinxResult, +) -> None: """No ``std:cmdoption`` entries and an empty std-domain progoptions table.""" - inventory = _load_inventory(no_index_result.outdir) + inventory = _load_inventory(no_index_result) assert inventory.get("std:cmdoption", {}) == {} std_domain = no_index_result.app.env.domains.standard_domain @@ -165,8 +127,10 @@ def test_no_index_registers_no_std_cmdoption(no_index_result: _Result) -> None: @pytest.mark.integration -def test_no_index_still_renders_the_card(no_index_result: _Result) -> None: +def test_no_index_still_renders_the_card( + no_index_result: SharedSphinxResult, +) -> None: """The parser still renders with per-section HTML anchors.""" - index_html = (no_index_result.outdir / "index.html").read_text(encoding="utf-8") + index_html = read_output(no_index_result, "index.html") assert "Usage" in index_html assert 'id="usage"' in index_html From a79b494d3a174cd996043aeccfa2cf017a3acabe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 15 Jun 2026 20:20:08 -0500 Subject: [PATCH 8/9] fastmcp,argparse(docs[doctests]): Doctest the new role factory and config field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The new _make_component_ref_role factory shipped without a doctest or NumPy parameter docs, and RenderConfig's class doctest didn't assert the new register_xref_targets default — gaps relative to the doctest-everything norm. what: - Add NumPy Parameters/Returns and a doctest to _make_component_ref_role asserting the placeholder's refkind/refslug/reftext - Assert register_xref_targets in the RenderConfig class doctest --- .../src/sphinx_autodoc_argparse/renderer.py | 2 ++ .../src/sphinx_autodoc_fastmcp/_roles.py | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/renderer.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/renderer.py index d66d5e09..d7d1b8ce 100644 --- a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/renderer.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/renderer.py @@ -48,6 +48,8 @@ class RenderConfig: True >>> config.group_title_prefix '' + >>> config.register_xref_targets + True """ group_title_prefix: str = "" diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py index 2e236143..7d7183d0 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py @@ -93,6 +93,29 @@ def _make_component_ref_role( The role renders an inline code literal linked to the component card; it carries no safety badge (only tools have a safety tier). + + Parameters + ---------- + refkind : str + Component family the role resolves against — ``"resource"`` or + ``"prompt"``. Stored on the placeholder so + :func:`sphinx_autodoc_fastmcp._transforms.resolve_component_refs` + picks the right canonical-id family. + + Returns + ------- + collections.abc.Callable + A docutils role function ``(name, rawtext, text, lineno, inliner, + options=None, content=None)`` returning ``([placeholder], [])``. + + Examples + -------- + >>> role = _make_component_ref_role("resource") + >>> emitted, messages = role("resource", "", "user_record", 0, None) + >>> emitted[0]["refkind"], emitted[0]["refslug"], emitted[0]["reftext"] + ('resource', 'user-record', 'user_record') + >>> messages + [] """ def role_fn( From ab55472b45be8ef43fc0234a02d3e02829c2dd46 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 15 Jun 2026 20:25:19 -0500 Subject: [PATCH 9/9] docs(CHANGES) Resource/prompt xref roles and :no-index: parity why: Record the forthcoming version's user-visible deliverables in the 0.0.1a31 unreleased block. what: - What's new: {resource}/{resourceref} and {prompt}/{promptref} cross-reference roles for sphinx-autodoc-fastmcp - What's new: :no-index: on the fastmcp prompt/resource/resource-template and argparse directives --- CHANGES | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGES b/CHANGES index deaaca3f..b3e6cce7 100644 --- a/CHANGES +++ b/CHANGES @@ -18,6 +18,23 @@ $ uv add gp-sphinx --prerelease allow +### What's new + +#### Cross-reference roles for resources and prompts + +`sphinx-autodoc-fastmcp` adds the `{resource}` / `{resourceref}` and +`{prompt}` / `{promptref}` inline roles, mirroring the existing +`{tool}` / `{toolref}` family. `{resource}` links a fixed resource or a +resource template by name, and an unknown name degrades to a plain +literal rather than a broken link. (#57) + +#### `:no-index:` for components shown on more than one page + +The `fastmcp-prompt`, `fastmcp-resource`, `fastmcp-resource-template`, +and `argparse` directives now accept `:no-index:`. Add it to every +appearance except the canonical one: the card still renders everywhere, +but registers its cross-reference target exactly once. (#57) + ## gp-sphinx 0.0.1a30 (2026-06-07) ### Fixes