Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,28 @@ $ uv add gp-sphinx --prerelease allow

<!-- To maintainers and contributors: Please add notes for the forthcoming version below -->

### Fixes

#### `sphinx-autodoc-fastmcp`: No duplicate IDs when a page heading matches a tool slug

A heading whose slug matched a tool's bare cross-reference alias
produced docutils duplicate-ID warnings and an ambiguous HTML anchor.
Tool cards now expose a single canonical `fastmcp-tool-<slug>` anchor;
bare `{ref}` and tool-role links keep resolving and target the
canonical anchor — scrolling the reader to the tool card instead of
the page heading. (#49)

```text
+------------------------------------------+
| # Delete buffer (H1) | <- #delete-buffer
| | heading links & old URLs land here
| +------------------------------------+ |
| | delete_buffer [mutating] [tool] | | <- #fastmcp-tool-delete-buffer
| | Delete an MCP-owned buffer. | | tool links land here
| +------------------------------------+ |
+------------------------------------------+
```

## gp-sphinx 0.0.1a26 (2026-05-25)

### Fixes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,16 @@ def _register_section_label(


def _component_ids(kind: str, name: str) -> tuple[str, list[str]]:
"""Derive canonical section id + back-compat aliases for a component.
"""Derive canonical section id + back-compat label 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.
keep the bare slug as a label alias because their unprefixed names were
the public ``{ref}`` shape on ``main`` and live in downstream user docs;
the alias resolves to the canonical ID and never becomes a physical
HTML id, so it cannot collide with a same-slug page heading.
Prompts/resources/templates have no such history, so they get the
canonical ID only.
Examples
--------
Expand All @@ -109,23 +111,43 @@ def _register_alias_if_free(
env: BuildEnvironment,
*,
alias: str,
target_id: str,
display_name: str,
kind: str,
) -> bool:
"""Register a bare-slug alias in std.labels iff currently unclaimed.
"""Register a bare-slug label alias for *target_id* iff 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.
The alias is a pure label: it points at the canonical section ID
(``std.labels[alias] = (docname, target_id, ...)``) and never becomes
a physical HTML id of its own, so a same-slug page heading keeps sole
ownership of the bare anchor (#48).
Returns True if the alias was registered, False if skipped.
Examples
--------
>>> import types
>>> std = types.SimpleNamespace(labels={}, anonlabels={})
>>> domains = types.SimpleNamespace(standard_domain=std)
>>> env = types.SimpleNamespace(docname="api", domains=domains)
>>> _register_alias_if_free(
... env,
... alias="delete-buffer",
... target_id="fastmcp-tool-delete-buffer",
... display_name="delete_buffer",
... kind="tool",
... )
True
>>> std.labels["delete-buffer"]
('api', 'fastmcp-tool-delete-buffer', 'delete_buffer')
>>> std.anonlabels["delete-buffer"]
('api', 'fastmcp-tool-delete-buffer')
"""
if kind != "tool":
msg = f"alias registration not permitted for kind={kind!r}"
Expand All @@ -136,7 +158,7 @@ def _register_alias_if_free(
if existing is not None:
existing_doc = existing[0]
existing_id = existing[1]
if (existing_doc, existing_id) != (env.docname, alias):
if (existing_doc, existing_id) != (env.docname, target_id):
logger.warning(
"sphinx_autodoc_fastmcp: bare alias %r for %s already claimed "
"by %s#%s; using canonical id only",
Expand All @@ -147,8 +169,8 @@ def _register_alias_if_free(
)
return False

std.anonlabels[alias] = (env.docname, alias)
std.labels[alias] = (env.docname, alias, display_name)
std.anonlabels[alias] = (env.docname, target_id)
std.labels[alias] = (env.docname, target_id, display_name)
return True


Expand All @@ -157,8 +179,8 @@ class FastMCPToolDirective(SphinxDirective):
Supports the standard Sphinx ``:no-index:`` flag (mirrors
:func:`autofunction`/:func:`autoclass` semantics): when set, the card
still renders in full but its canonical section ID and bare-slug alias
are not registered in :class:`StandardDomain` ``labels`` /
still renders in full but its canonical section ID and bare-slug label
alias are not registered in :class:`StandardDomain` ``labels`` /
``anonlabels``. Use it when a tool needs to appear visually on more
than one page (e.g. a gallery demo + a reference page) — exactly one
invocation per tool should omit ``:no-index:`` so cross-references
Expand Down Expand Up @@ -200,21 +222,26 @@ def _build_tool_section(self, tool: ToolInfo) -> list[nodes.Node]:

section = nodes.section()
section["ids"].append(section_id)
section["ids"].extend(aliases)
section["classes"].extend((_CSS.TOOL_SECTION, API.CARD_SHELL))
if no_index:
# Marker consumed by ``register_tool_labels`` in _transforms.py
# so the doctree-read pass mirrors the directive's skip.
section["fastmcp_no_index"] = True
else:
_register_section_label(self.env, section_id, tool.name)
for alias in aliases:
_register_alias_if_free(
# Marker consumed by ``register_tool_labels`` so incremental
# rebuilds restore the same alias labels from the doctree cache.
section["fastmcp_alias_labels"] = [
alias
for alias in aliases
if _register_alias_if_free(
self.env,
alias=alias,
target_id=section_id,
display_name=tool.name,
kind="tool",
)
]
document.note_explicit_target(section)

title_node = nodes.title("", "")
Expand Down Expand Up @@ -401,7 +428,7 @@ def run(self) -> list[nodes.Node]:
for tool in sorted(tier_tools, key=lambda x: x.name):
first_line = first_paragraph(tool.docstring)
ref = nodes.reference("", "", internal=True)
ref["refuri"] = f"{tool.area}/#{tool.name.replace('_', '-')}"
ref["refuri"] = f"{tool.area}/#{_component_ids('tool', tool.name)[0]}"
ref += nodes.literal("", tool.name)
rows.append(
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,12 @@ def collect_tool_section_content(app: Sphinx, doctree: nodes.document) -> None:
def register_tool_labels(app: Sphinx, doctree: nodes.document) -> None:
"""Mirror autosectionlabel for fastmcp card sections (``{ref}`<id>```).

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.
Re-registers labels for every id on each card section, plus the
back-compat bare-slug aliases stored in the section's
``fastmcp_alias_labels`` attribute (pointing at the canonical id),
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
Expand All @@ -97,6 +98,11 @@ def register_tool_labels(app: Sphinx, doctree: nodes.document) -> None:
for section_id in section["ids"]:
domain.anonlabels[section_id] = (docname, section_id)
domain.labels[section_id] = (docname, section_id, tool_name)
canonical_id = section["ids"][0]
aliases: list[str] = section.get("fastmcp_alias_labels", [])
for alias in aliases:
domain.anonlabels[alias] = (docname, canonical_id)
domain.labels[alias] = (docname, canonical_id, tool_name)


def add_section_badges(
Expand Down
Loading