Skip to content

Commit 4f6e509

Browse files
committed
feat(docs[_ext]): Support vanilla and badged tool cross-reference styles
why: {ref}`capture-pane` rendered as "capture_pane readonly" because the label title included the safety badge text. Users need both a clean ref style and a badged style. what: - Fix _register_tool_labels to extract just the tool name (no badge) from the section title's first literal child - {ref}`capture-pane` now renders as clean "capture_pane" (vanilla) - Add {tool} role for badged refs: {tool}`capture-pane` renders as "capture_pane [readonly]" with colored badge span - Custom placeholder node resolved at doctree-resolved to avoid conflicts with Sphinx's ReferencesResolver
1 parent 108b923 commit 4f6e509

1 file changed

Lines changed: 83 additions & 2 deletions

File tree

docs/_ext/fastmcp_autodoc.py

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,10 @@ def _register_tool_labels(app: Sphinx, doctree: nodes.document) -> None:
713713
StandardDomain. This hook mirrors the pattern used by
714714
``sphinx.ext.autosectionlabel`` so that ``{ref}`list-sessions``` works
715715
from any page.
716+
717+
The primary label uses just the tool name (no safety badge) so that
718+
``{ref}`` renders a clean ``tool_name`` link. Use ``{tool}`` role
719+
for a link that includes the colored safety badge.
716720
"""
717721
domain = t.cast("StandardDomain", app.env.get_domain("std"))
718722
docname = app.env.docname
@@ -721,15 +725,92 @@ def _register_tool_labels(app: Sphinx, doctree: nodes.document) -> None:
721725
continue
722726
section_id = section["ids"][0]
723727
if section.children and isinstance(section[0], nodes.title):
724-
title = section[0].astext()
728+
# Extract just the tool name from the first literal child,
729+
# ignoring the safety badge that follows it.
730+
title_node = section[0]
731+
tool_name = ""
732+
for child in title_node.children:
733+
if isinstance(child, nodes.literal):
734+
tool_name = child.astext()
735+
break
736+
if not tool_name:
737+
tool_name = title_node.astext()
725738
domain.anonlabels[section_id] = (docname, section_id)
726-
domain.labels[section_id] = (docname, section_id, title)
739+
domain.labels[section_id] = (docname, section_id, tool_name)
740+
741+
742+
class _tool_ref_placeholder(nodes.General, nodes.Inline, nodes.Element):
743+
"""Placeholder node for ``{tool}`` role, resolved at doctree-resolved."""
744+
745+
746+
def _resolve_tool_refs(
747+
app: Sphinx,
748+
doctree: nodes.document,
749+
fromdocname: str,
750+
) -> None:
751+
"""Resolve ``{tool}`` placeholders into links with safety badges.
752+
753+
Runs at ``doctree-resolved`` — after all labels are registered and
754+
standard ``{ref}`` resolution is done.
755+
"""
756+
domain = t.cast("StandardDomain", app.env.get_domain("std"))
757+
builder = app.builder
758+
tool_data: dict[str, ToolInfo] = getattr(app.env, "fastmcp_tools", {})
759+
760+
for node in list(doctree.findall(_tool_ref_placeholder)):
761+
target = node.get("reftarget", "")
762+
label_info = domain.labels.get(target)
763+
if label_info is None:
764+
node.replace_self(nodes.literal("", target.replace("-", "_")))
765+
continue
766+
767+
todocname, labelid, _title = label_info
768+
tool_name = target.replace("-", "_")
769+
770+
newnode = nodes.reference("", "", internal=True)
771+
try:
772+
newnode["refuri"] = builder.get_relative_uri(fromdocname, todocname)
773+
if labelid:
774+
newnode["refuri"] += "#" + labelid
775+
except Exception:
776+
newnode["refuri"] = "#" + labelid
777+
newnode["classes"].append("reference")
778+
newnode["classes"].append("internal")
779+
780+
newnode += nodes.literal("", tool_name)
781+
782+
tool_info = tool_data.get(tool_name)
783+
if tool_info:
784+
newnode += nodes.Text(" ")
785+
newnode += _safety_badge(tool_info.safety)
786+
787+
node.replace_self(newnode)
788+
789+
790+
def _tool_role(
791+
name: str,
792+
rawtext: str,
793+
text: str,
794+
lineno: int,
795+
inliner: object,
796+
options: dict[str, object] | None = None,
797+
content: list[str] | None = None,
798+
) -> tuple[list[nodes.Node], list[nodes.system_message]]:
799+
"""Inline role ``:tool:`capture-pane``` → linked tool name + safety badge.
800+
801+
Creates a placeholder node resolved later by ``_resolve_tool_refs``.
802+
"""
803+
target = text.strip().replace("_", "-")
804+
node = _tool_ref_placeholder(rawtext, reftarget=target)
805+
return [node], []
727806

728807

729808
def setup(app: Sphinx) -> ExtensionMetadata:
730809
"""Register the fastmcp_autodoc extension."""
731810
app.connect("builder-inited", _collect_tools)
732811
app.connect("doctree-read", _register_tool_labels)
812+
app.connect("doctree-resolved", _resolve_tool_refs)
813+
app.add_role("tool", _tool_role)
733814
app.add_directive("fastmcp-tool", FastMCPToolDirective)
734815
app.add_directive("fastmcp-tool-input", FastMCPToolInputDirective)
735816
app.add_directive("fastmcp-toolsummary", FastMCPToolSummaryDirective)

0 commit comments

Comments
 (0)