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
17 changes: 17 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ $ uv add gp-sphinx --prerelease allow

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

### 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
Expand Down
21 changes: 21 additions & 0 deletions docs/packages/sphinx-autodoc-fastmcp/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,20 @@ class RenderConfig:
True
>>> config.group_title_prefix
''
>>> config.register_xref_targets
True
"""

group_title_prefix: str = ""
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:
Expand Down Expand Up @@ -194,7 +202,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
Expand Down Expand Up @@ -233,7 +241,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]
Expand Down Expand Up @@ -265,7 +273,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]
Expand Down Expand Up @@ -443,7 +451,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()
Expand Down Expand Up @@ -518,10 +528,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -42,6 +46,7 @@
badge_role,
collect_tool_section_content,
register_tool_labels,
resolve_component_refs,
resolve_tool_refs,
)

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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)
Expand Down Expand Up @@ -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] = []
Expand All @@ -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)
Expand All @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down
Loading