diff --git a/docs/_ext/fastmcp_autodoc.py b/docs/_ext/fastmcp_autodoc.py new file mode 100644 index 00000000..b5e96d76 --- /dev/null +++ b/docs/_ext/fastmcp_autodoc.py @@ -0,0 +1,719 @@ +"""Sphinx extension for autodocumenting FastMCP tools. + +Builds documentation directly from docutils/Sphinx node API — no text +generation or markdown parsing. Tables, sections, and cross-references +are all proper doctree nodes. + +Provides two directives: + +- ``fastmcp-tool``: Autodocument a single MCP tool function. + Creates a section (visible in ToC) with safety badge, parameter table, + and return type. +- ``fastmcp-toolsummary``: Generate a summary table of all tools grouped + by safety tier. + +Usage in MyST:: + + ```{fastmcp-tool} server_tools.list_sessions + ``` + + ```{fastmcp-toolsummary} + ``` +""" + +from __future__ import annotations + +import importlib +import inspect +import re +import typing as t +from dataclasses import dataclass + +from docutils import nodes +from sphinx.application import Sphinx +from sphinx.util.docutils import SphinxDirective + +if t.TYPE_CHECKING: + from sphinx.util.typing import ExtensionMetadata + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +AREA_MAP: dict[str, str] = { + "server_tools": "sessions", + "session_tools": "sessions", + "window_tools": "windows", + "pane_tools": "panes", + "option_tools": "options", + "env_tools": "options", +} + +TAG_READONLY = "readonly" +TAG_MUTATING = "mutating" +TAG_DESTRUCTIVE = "destructive" + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + + +@dataclass +class ParamInfo: + """Extracted parameter information.""" + + name: str + type_str: str + required: bool + default: str + description: str + + +@dataclass +class ToolInfo: + """Collected metadata for a single MCP tool.""" + + name: str + title: str + module_name: str + area: str + safety: str + annotations: dict[str, bool] + func: t.Callable[..., t.Any] + docstring: str + params: list[ParamInfo] + return_annotation: str + + +# --------------------------------------------------------------------------- +# Docstring + signature parsing +# --------------------------------------------------------------------------- + + +def _parse_numpy_params(docstring: str) -> dict[str, str]: + """Extract parameter descriptions from NumPy-style docstring.""" + params: dict[str, str] = {} + if not docstring: + return params + + lines = docstring.split("\n") + in_params = False + current_param: str | None = None + current_desc: list[str] = [] + + for line in lines: + stripped = line.strip() + indent = len(line) - len(line.lstrip()) + + if stripped == "Parameters": + in_params = True + continue + if in_params and stripped.startswith("---"): + continue + if in_params and stripped in ( + "Returns", + "Raises", + "Notes", + "Examples", + "See Also", + ): + if current_param: + params[current_param] = " ".join(current_desc).strip() + break + if in_params and not stripped: + continue + + if in_params: + param_match = re.match(r"^(\w+)\s*:", stripped) + if param_match and indent == 0: + if current_param: + params[current_param] = " ".join(current_desc).strip() + current_param = param_match.group(1) + current_desc = [] + elif current_param and indent > 0: + current_desc.append(stripped) + + if current_param: + params[current_param] = " ".join(current_desc).strip() + + return params + + +def _first_paragraph(docstring: str) -> str: + """Extract the first paragraph from a docstring.""" + if not docstring: + return "" + paragraphs = docstring.strip().split("\n\n") + return paragraphs[0].strip().replace("\n", " ") + + +def _format_annotation(ann: t.Any, *, strip_none: bool = False) -> str: + """Format a type annotation as a readable string. + + Parameters + ---------- + ann : Any + The annotation to format. + strip_none : bool + If True, remove ``| None`` from union types. Useful when the + parameter is already marked as optional. + """ + if ann is inspect.Parameter.empty: + return "" + if isinstance(ann, str): + result = ann + # Clean up t.Literal['a', 'b'] or Literal['a', 'b'] → 'a', 'b' + result = re.sub( + r"(?:t\.)?Literal\[([^\]]+)\]", + lambda m: m.group(1), + result, + ) + if strip_none: + result = re.sub(r"\s*\|\s*None\b", "", result).strip() + return result + if hasattr(ann, "__name__"): + return str(ann.__name__) + return str(ann).replace("typing.", "") + + +def _extract_params(func: t.Callable[..., t.Any]) -> list[ParamInfo]: + """Extract parameter info from function signature + docstring.""" + sig = inspect.signature(func) + doc_params = _parse_numpy_params(func.__doc__ or "") + params: list[ParamInfo] = [] + + for name, param in sig.parameters.items(): + is_optional = param.default != inspect.Parameter.empty + type_str = _format_annotation( + param.annotation, + strip_none=is_optional, + ) + + if is_optional: + if param.default is None: + default_str = "None" + elif isinstance(param.default, bool): + default_str = str(param.default) + elif isinstance(param.default, str): + default_str = repr(param.default) + else: + default_str = str(param.default) + required = False + else: + default_str = "" + required = True + + params.append( + ParamInfo( + name=name, + type_str=type_str, + required=required, + default=default_str, + description=doc_params.get(name, ""), + ) + ) + + return params + + +# --------------------------------------------------------------------------- +# Node construction helpers +# --------------------------------------------------------------------------- + + +def _make_table( + headers: list[str], + rows: list[list[str | nodes.Node]], + col_widths: list[int] | None = None, +) -> nodes.table: + """Build a docutils table node from headers and rows.""" + ncols = len(headers) + if col_widths is None: + col_widths = [100 // ncols] * ncols + + table = nodes.table("") + tgroup = nodes.tgroup("", cols=ncols) + table += tgroup + + for width in col_widths: + tgroup += nodes.colspec("", colwidth=width) + + # Header row + thead = nodes.thead("") + header_row = nodes.row("") + for header in headers: + entry = nodes.entry("") + entry += nodes.paragraph("", header) + header_row += entry + thead += header_row + tgroup += thead + + # Body rows + tbody = nodes.tbody("") + for row_data in rows: + row = nodes.row("") + for cell in row_data: + entry = nodes.entry("") + if isinstance(cell, nodes.Node): + entry += cell + else: + entry += nodes.paragraph("", str(cell)) + row += entry + tbody += row + tgroup += tbody + + return table + + +def _make_literal(text: str) -> nodes.literal: + """Create an inline code literal node.""" + return nodes.literal("", text) + + +def _make_para(*children: nodes.Node | str) -> nodes.paragraph: + """Create a paragraph from mixed text and node children.""" + para = nodes.paragraph("") + for child in children: + if isinstance(child, str): + para += nodes.Text(child) + else: + para += child + return para + + +def _parse_rst_inline( + text: str, + state: t.Any, + lineno: int, +) -> nodes.paragraph: + """Parse a string containing RST inline markup into a paragraph node. + + Handles ``code``, *emphasis*, **strong**, :role:`ref`, etc. + """ + parsed_nodes, _messages = state.inline_text(text, lineno) + para = nodes.paragraph("") + para += parsed_nodes + return para + + +def _make_type_cell(type_str: str) -> nodes.paragraph: + """Render a type annotation as comma-separated code literals. + + ``dict[str, str] | str`` becomes ``dict[str, str]``, ``str`` + ``'server', 'session', 'window'`` becomes ``'server'``, ``'session'``, ... + — each part in its own element so they wrap cleanly. + """ + # Split on | for union types + parts = [p.strip() for p in type_str.split("|")] + + # Further split quoted literal values: 'a', 'b', 'c' + expanded: list[str] = [] + for part in parts: + if re.match(r"^'[^']*'(\s*,\s*'[^']*')+$", part): + # Multiple quoted values like 'server', 'session', 'window' + expanded.extend(v.strip() for v in part.split(",")) + else: + expanded.append(part) + + para = nodes.paragraph("") + for i, part in enumerate(expanded): + if i > 0: + para += nodes.Text(", ") + para += nodes.literal("", part) + return para + + +def _make_type_cell_smart( + type_str: str, +) -> tuple[nodes.paragraph | str, bool]: + """Render a type annotation, detecting enum-only types. + + Returns (node, is_enum). If the type is purely quoted literal + values, returns ``enum`` as the type and True so the caller + can append the values to the description column instead. + """ + if not type_str: + return ("", False) + + parts = [p.strip() for p in type_str.split("|")] + + # Check if ALL parts are quoted strings (Literal enum values) + all_quoted = all(re.match(r"^'[^']*'$", p) for p in parts) + # Also handle comma-separated quoted values from Literal cleanup + if not all_quoted and len(parts) == 1: + sub = [s.strip() for s in parts[0].split(",")] + all_quoted = len(sub) > 1 and all(re.match(r"^'[^']*'$", s) for s in sub) + + if all_quoted: + return (_make_para(_make_literal("enum")), True) + + return (_make_type_cell(type_str), False) + + +def _extract_enum_values(type_str: str) -> list[str]: + """Extract individual enum values from a Literal type string.""" + parts = [p.strip() for p in type_str.split("|")] + values: list[str] = [] + for part in parts: + for sub in part.split(","): + sub = sub.strip() + if re.match(r"^'[^']*'$", sub): + values.append(sub) + return values + + +def _safety_badge(safety: str) -> nodes.inline: + """Create a colored safety badge node.""" + _base = ["sd-sphinx-override", "sd-badge"] + classes = { + "readonly": [*_base, "sd-bg-success", "sd-bg-text-success"], + "mutating": [*_base, "sd-bg-warning", "sd-bg-text-warning"], + "destructive": [*_base, "sd-bg-danger", "sd-bg-text-danger"], + } + badge = nodes.inline("", safety, classes=classes.get(safety, [])) + return badge + + +# --------------------------------------------------------------------------- +# Tool collection (runs at builder-inited) +# --------------------------------------------------------------------------- + + +class _ToolCollector: + """Mock FastMCP that captures tool registrations.""" + + def __init__(self) -> None: + self.tools: list[ToolInfo] = [] + self._current_module: str = "" + + def tool( + self, + title: str = "", + annotations: dict[str, bool] | None = None, + tags: set[str] | None = None, + ) -> t.Callable[[t.Callable[..., t.Any]], t.Callable[..., t.Any]]: + annotations = annotations or {} + tags = tags or set() + + def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + if TAG_DESTRUCTIVE in tags: + safety = "destructive" + elif TAG_MUTATING in tags: + safety = "mutating" + else: + safety = "readonly" + + module_name = self._current_module + area = AREA_MAP.get(module_name, module_name.replace("_tools", "")) + + self.tools.append( + ToolInfo( + name=func.__name__, + title=title or func.__name__.replace("_", " ").title(), + module_name=module_name, + area=area, + safety=safety, + annotations=annotations, + func=func, + docstring=func.__doc__ or "", + params=_extract_params(func), + return_annotation=_format_annotation( + inspect.signature(func).return_annotation, + ), + ) + ) + return func + + return decorator + + +def _collect_tools(app: Sphinx) -> None: + """Collect tool metadata from libtmux_mcp source at build time.""" + collector = _ToolCollector() + + tool_modules = [ + "server_tools", + "session_tools", + "window_tools", + "pane_tools", + "option_tools", + "env_tools", + ] + + for mod_name in tool_modules: + collector._current_module = mod_name + try: + mod = importlib.import_module(f"libtmux_mcp.tools.{mod_name}") + if hasattr(mod, "register"): + mod.register(collector) + except Exception: + pass # Module not importable during docs build + + app.env.fastmcp_tools = {tool.name: tool for tool in collector.tools} # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# Directives +# --------------------------------------------------------------------------- + + +class FastMCPToolDirective(SphinxDirective): + """Autodocument a single MCP tool as a proper section with table. + + Creates a section node (visible in ToC) containing: + - Safety badge + one-line description + - Parameter table (headers: Parameter, Type, Required, Default, Description) + - Return type + + Usage:: + + ```{fastmcp-tool} server_tools.list_sessions + ``` + """ + + required_arguments = 1 + optional_arguments = 0 + has_content = True + final_argument_whitespace = False + + def run(self) -> list[nodes.Node]: + """Build tool section header nodes.""" + arg = self.arguments[0] + func_name = arg.split(".")[-1] if "." in arg else arg + + tools: dict[str, ToolInfo] = getattr(self.env, "fastmcp_tools", {}) + tool = tools.get(func_name) + + if tool is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-tool: tool '{func_name}' not found. " + f"Available: {', '.join(sorted(tools.keys()))}", + line=self.lineno, + ) + ] + + return self._build_tool_section(tool) + + def _build_tool_section(self, tool: ToolInfo) -> list[nodes.Node]: + """Build section header: title, badge, and description only. + + The parameter table is emitted separately by + ``FastMCPToolInputDirective`` so that hand-written judgment + content (Use when / Avoid when / examples) can appear between + the header and the table in the source file. + """ + document = self.state.document + + # Section with anchor ID + section_id = tool.name.replace("_", "-") + section = nodes.section() + section["ids"].append(section_id) + document.note_explicit_target(section) + + # Title: tool name + safety badge inline + title_node = nodes.title("", "") + title_node += nodes.literal("", tool.name) + title_node += nodes.Text(" ") + title_node += _safety_badge(tool.safety) + section += title_node + + # Description paragraph + first_para = _first_paragraph(tool.docstring) + desc_para = _parse_rst_inline(first_para, self.state, self.lineno) + section += desc_para + + # Returns (promoted — high-signal for tool selection) + if tool.return_annotation: + section += _make_para( + nodes.strong("", "Returns: "), + _make_literal(tool.return_annotation), + ) + + return [section] + + +class FastMCPToolInputDirective(SphinxDirective): + """Emit the parameter table and return type for a tool. + + Place this AFTER hand-written judgment content so the table + appears at the end of the tool section. + + Usage:: + + ```{fastmcp-tool-input} server_tools.list_sessions + ``` + """ + + required_arguments = 1 + optional_arguments = 0 + has_content = False + + def run(self) -> list[nodes.Node]: + """Build parameter table and return type nodes.""" + arg = self.arguments[0] + func_name = arg.split(".")[-1] if "." in arg else arg + + tools: dict[str, ToolInfo] = getattr(self.env, "fastmcp_tools", {}) + tool = tools.get(func_name) + + if tool is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-tool-input: tool '{func_name}' not found.", + line=self.lineno, + ) + ] + + result: list[nodes.Node] = [] + + # Parameter table + if tool.params: + result.append(_make_para(nodes.strong("", "Parameters"))) + headers = ["Parameter", "Type", "Required", "Default", "Description"] + rows: list[list[str | nodes.Node]] = [] + for p in tool.params: + # Build description node — parse RST inline markup + desc_node = self._build_description(p) + + # Type cell — detect enum-only types and simplify + type_cell, is_enum = _make_type_cell_smart(p.type_str) + + # If enum, append allowed values to description + if is_enum and p.type_str: + enum_values = _extract_enum_values(p.type_str) + if enum_values: + desc_node += nodes.Text(" One of: ") + for i, val in enumerate(enum_values): + if i > 0: + desc_node += nodes.Text(", ") + desc_node += nodes.literal("", val) + desc_node += nodes.Text(".") + + # Default — suppress "None" as visual noise + default_cell: str | nodes.Node = "—" + if p.default and p.default != "None": + default_cell = _make_para(_make_literal(p.default)) + + rows.append( + [ + _make_para(_make_literal(p.name)), + type_cell, + "yes" if p.required else "no", + default_cell, + desc_node, + ] + ) + result.append( + _make_table(headers, rows, col_widths=[15, 15, 8, 10, 52]), + ) + + return result + + def _build_description(self, p: ParamInfo) -> nodes.paragraph: + """Build a description paragraph, parsing RST inline markup.""" + if p.description: + return _parse_rst_inline( + p.description, + self.state, + self.lineno, + ) + return nodes.paragraph("", "—") + + +class FastMCPToolSummaryDirective(SphinxDirective): + """Generate a summary table of all tools grouped by safety tier. + + Produces three tables (Inspect, Act, Destroy) with tool names + linked to their sections on area pages. + + Usage:: + + ```{fastmcp-toolsummary} + ``` + """ + + required_arguments = 0 + optional_arguments = 0 + has_content = False + + def run(self) -> list[nodes.Node]: + """Build grouped summary tables.""" + tools: dict[str, ToolInfo] = getattr(self.env, "fastmcp_tools", {}) + + if not tools: + return [ + self.state.document.reporter.warning( + "fastmcp-toolsummary: no tools found.", + line=self.lineno, + ) + ] + + groups: dict[str, list[ToolInfo]] = { + "readonly": [], + "mutating": [], + "destructive": [], + } + for tool in tools.values(): + groups.setdefault(tool.safety, []).append(tool) + + result_nodes: list[nodes.Node] = [] + + tier_order = [ + ("readonly", "Inspect", "Read tmux state without changing anything."), + ("mutating", "Act", "Create or modify tmux objects."), + ("destructive", "Destroy", "Tear down tmux objects. Not reversible."), + ] + + for safety, label, desc in tier_order: + tier_tools = groups.get(safety, []) + if not tier_tools: + continue + + # Section for this tier + section = nodes.section() + section["ids"].append(label.lower()) + self.state.document.note_explicit_target(section) + section += nodes.title("", label) + section += nodes.paragraph("", desc) + + # Summary table + headers = ["Tool", "Description"] + rows: list[list[str | nodes.Node]] = [] + for tool in sorted(tier_tools, key=lambda t: t.name): + first_line = _first_paragraph(tool.docstring) + # Link to the tool's section on its area page + ref = nodes.reference("", "", internal=True) + ref["refuri"] = f"{tool.area}/#{tool.name.replace('_', '-')}" + ref += nodes.literal("", tool.name) + rows.append( + [ + _make_para(ref), + _parse_rst_inline(first_line, self.state, self.lineno), + ] + ) + section += _make_table(headers, rows, col_widths=[30, 70]) + + result_nodes.append(section) + + return result_nodes + + +# --------------------------------------------------------------------------- +# Extension setup +# --------------------------------------------------------------------------- + + +def setup(app: Sphinx) -> ExtensionMetadata: + """Register the fastmcp_autodoc extension.""" + app.connect("builder-inited", _collect_tools) + app.add_directive("fastmcp-tool", FastMCPToolDirective) + app.add_directive("fastmcp-tool-input", FastMCPToolInputDirective) + app.add_directive("fastmcp-toolsummary", FastMCPToolSummaryDirective) + + return { + "version": "0.1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 90327735..6b0c04a8 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -232,3 +232,8 @@ img[src*="codecov.io"] { ::view-transition-new(root) { animation-duration: 150ms; } + +/* ── MCP Tool safety badges ────────────────────────────── + * Badge sits inline next to the tool name in the heading. + * Slight vertical offset for alignment with the code text. + * ────────────────────────────────────────────────────────── */ diff --git a/docs/conf.py b/docs/conf.py index ab556684..c0cc3450 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -43,6 +43,7 @@ "sphinx_design", "myst_parser", "linkify_issues", + "fastmcp_autodoc", ] myst_heading_anchors = 4 diff --git a/docs/tools/index.md b/docs/tools/index.md index 0a5ef710..41b057ba 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -4,114 +4,200 @@ All tools accept an optional `socket_name` parameter for multi-server support. It defaults to the `LIBTMUX_SOCKET` env var. See {ref}`configuration`. -::::{grid} 1 1 2 2 +## Inspect + +Read tmux state without changing anything. + +::::{grid} 1 2 3 3 :gutter: 2 2 3 3 -:::{grid-item-card} Discovery -Find and inspect tmux objects. -^^^ -`list_sessions` `list_windows` `list_panes` `get_server_info` `get_pane_info` +:::{grid-item-card} list_sessions +:link: sessions +:link-type: doc +List all active sessions. ::: -:::{grid-item-card} Capture & Search -Read and search terminal output. -^^^ -`capture_pane` `search_panes` `wait_for_text` +:::{grid-item-card} list_windows +:link: windows +:link-type: doc +List windows in a session. ::: -:::{grid-item-card} Session Lifecycle -Create and manage sessions. -^^^ -`create_session` `rename_session` `kill_session` +:::{grid-item-card} list_panes +:link: windows +:link-type: doc +List panes in a window. ::: -:::{grid-item-card} Windows & Panes -Create, split, and organize. -^^^ -`create_window` `split_window` `rename_window` `select_layout` `resize_window` `resize_pane` `kill_window` `kill_pane` +:::{grid-item-card} capture_pane +:link: panes +:link-type: doc +Read visible content of a pane. ::: -:::{grid-item-card} Execution -Send commands and interact with terminals. -^^^ -`send_keys` `set_pane_title` `clear_pane` +:::{grid-item-card} get_pane_info +:link: panes +:link-type: doc +Get detailed pane metadata. ::: -:::{grid-item-card} Options & Environment -Read and set tmux configuration. -^^^ -`show_option` `set_option` `show_environment` `set_environment` +:::{grid-item-card} search_panes +:link: panes +:link-type: doc +Search text across panes. ::: -:::{grid-item-card} Server Management -Destructive server operations. -^^^ -`kill_server` +:::{grid-item-card} wait_for_text +:link: panes +:link-type: doc +Wait for text to appear in a pane. +::: + +:::{grid-item-card} get_server_info +:link: sessions +:link-type: doc +Get tmux server info. +::: + +:::{grid-item-card} show_option +:link: options +:link-type: doc +Query a tmux option value. +::: + +:::{grid-item-card} show_environment +:link: options +:link-type: doc +Show tmux environment variables. ::: :::: -## Discovery +## Act + +Create or modify tmux objects. + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} create_session +:link: sessions +:link-type: doc +Start a new tmux session. +::: + +:::{grid-item-card} create_window +:link: windows +:link-type: doc +Add a window to a session. +::: + +:::{grid-item-card} split_window +:link: windows +:link-type: doc +Split a window into panes. +::: -Find and inspect tmux objects. +:::{grid-item-card} send_keys +:link: panes +:link-type: doc +Send commands or keystrokes to a pane. +::: -- **`list_sessions`** — List all sessions (with optional filters) -- **`list_windows`** — List windows in a session or across all sessions -- **`list_panes`** — List panes in a window or across all windows -- **`get_server_info`** — Server status: version, socket path, session count, alive status -- **`get_pane_info`** — Pane metadata: size, title, current command, PID +:::{grid-item-card} rename_session +:link: sessions +:link-type: doc +Rename a session. +::: -## Capture and search +:::{grid-item-card} rename_window +:link: windows +:link-type: doc +Rename a window. +::: -Read and search terminal output. +:::{grid-item-card} resize_pane +:link: panes +:link-type: doc +Adjust pane dimensions. +::: -- **`capture_pane`** — Capture pane content as text (visible area or scrollback) -- **`search_panes`** — Search across all pane contents for text or regex -- **`wait_for_text`** — Wait for text to appear in a pane (polling with timeout) +:::{grid-item-card} resize_window +:link: windows +:link-type: doc +Adjust window dimensions. +::: -## Session lifecycle +:::{grid-item-card} select_layout +:link: windows +:link-type: doc +Set window layout. +::: -Create and manage sessions. +:::{grid-item-card} set_pane_title +:link: panes +:link-type: doc +Set pane title. +::: -- **`create_session`** — Create a new session with optional window name, size, and env vars -- **`rename_session`** — Rename an existing session -- **`kill_session`** — Kill a session (destructive) +:::{grid-item-card} clear_pane +:link: panes +:link-type: doc +Clear pane content. +::: -## Windows and panes +:::{grid-item-card} set_option +:link: options +:link-type: doc +Set a tmux option. +::: -Create, split, and organize. +:::{grid-item-card} set_environment +:link: options +:link-type: doc +Set a tmux environment variable. +::: -- **`create_window`** — Create a new window in a session -- **`split_window`** — Split a window to create a new pane (horizontal or vertical) -- **`rename_window`** — Rename a window -- **`select_layout`** — Set layout: `even-horizontal`, `even-vertical`, `main-horizontal`, `main-vertical`, `tiled` -- **`resize_window`** — Resize a window (width and/or height) -- **`resize_pane`** — Resize a pane (width, height, or zoom toggle) -- **`kill_window`** — Kill a window (destructive) -- **`kill_pane`** — Kill a pane (destructive) +:::: -## Execution +## Destroy -Send commands and interact with terminals. +Tear down tmux objects. Not reversible. -- **`send_keys`** — Send keys or text to a pane (with optional Enter, literal mode, history suppression) -- **`set_pane_title`** — Set a pane's title -- **`clear_pane`** — Clear pane content and scrollback history +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 -## Options and environment +:::{grid-item-card} kill_session +:link: sessions +:link-type: doc +Destroy a session and all its windows. +::: -Read and set tmux configuration. +:::{grid-item-card} kill_window +:link: windows +:link-type: doc +Destroy a window and all its panes. +::: -- **`show_option`** — Query a tmux option value (server, session, window, or pane scope) -- **`set_option`** — Set a tmux option -- **`show_environment`** — Show tmux environment variables -- **`set_environment`** — Set a tmux environment variable +:::{grid-item-card} kill_pane +:link: panes +:link-type: doc +Destroy a pane. +::: -## Server management +:::{grid-item-card} kill_server +:link: sessions +:link-type: doc +Kill the entire tmux server. +::: -- **`kill_server`** — Kill the tmux server (destructive) +:::: -## Tool parameter reference +```{toctree} +:hidden: -For full parameter documentation (types, defaults, descriptions), see the -[API reference](../reference/api/index.md). +sessions +windows +panes +options +``` diff --git a/docs/tools/options.md b/docs/tools/options.md new file mode 100644 index 00000000..fa42eae5 --- /dev/null +++ b/docs/tools/options.md @@ -0,0 +1,74 @@ +# Options & Environment + +## Inspect + +```{fastmcp-tool} option_tools.show_option +``` + +**Use when** you need to check a tmux configuration value — buffer limits, +history size, status bar settings, etc. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "show_option", + "arguments": { + "option": "history-limit" + } +} +``` + +```{fastmcp-tool-input} option_tools.show_option +``` + +--- + +```{fastmcp-tool} env_tools.show_environment +``` + +**Use when** you need to inspect tmux environment variables. + +**Side effects:** None. Readonly. + +```{fastmcp-tool-input} env_tools.show_environment +``` + +## Act + +```{fastmcp-tool} option_tools.set_option +``` + +**Use when** you need to change tmux behavior — adjusting history limits, +enabling mouse support, changing status bar format. + +**Side effects:** Changes the tmux option value. + +**Example:** + +```json +{ + "tool": "set_option", + "arguments": { + "option": "history-limit", + "value": "50000" + } +} +``` + +```{fastmcp-tool-input} option_tools.set_option +``` + +--- + +```{fastmcp-tool} env_tools.set_environment +``` + +**Use when** you need to set a tmux environment variable. + +**Side effects:** Sets the variable in the tmux server. + +```{fastmcp-tool-input} env_tools.set_environment +``` diff --git a/docs/tools/panes.md b/docs/tools/panes.md new file mode 100644 index 00000000..791c2411 --- /dev/null +++ b/docs/tools/panes.md @@ -0,0 +1,183 @@ +# Panes + +## Inspect + +```{fastmcp-tool} pane_tools.capture_pane +``` + +**Use when** you need to read what's currently displayed in a terminal — +after running a command, checking output, or verifying state. + +**Avoid when** you need to search across multiple panes at once — use +{ref}`search-panes`. If you only need pane metadata (not content), use +{ref}`get-pane-info`. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "capture_pane", + "arguments": { + "session_name": "dev", + "start": -50 + } +} +``` + +```{fastmcp-tool-input} pane_tools.capture_pane +``` + +--- + +```{fastmcp-tool} pane_tools.get_pane_info +``` + +**Use when** you need pane dimensions, PID, current working directory, or +other metadata without reading the terminal content. + +**Avoid when** you need the actual text — use {ref}`capture-pane`. + +**Side effects:** None. Readonly. + +```{fastmcp-tool-input} pane_tools.get_pane_info +``` + +--- + +```{fastmcp-tool} pane_tools.search_panes +``` + +**Use when** you need to find specific text across multiple panes — locating +which pane has an error, finding a running process, or checking output +without knowing which pane to look in. + +**Avoid when** you already know the target pane — use {ref}`capture-pane` +directly. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "search_panes", + "arguments": { + "query": "error", + "session_name": "dev" + } +} +``` + +```{fastmcp-tool-input} pane_tools.search_panes +``` + +--- + +```{fastmcp-tool} pane_tools.wait_for_text +``` + +**Use when** you need to block until specific output appears — waiting for a +server to start, a build to complete, or a prompt to return. + +**Avoid when** you can poll with {ref}`capture-pane` instead, or if the +expected text may never appear (set a timeout). + +**Side effects:** None. Readonly. Blocks until text appears or timeout. + +**Example:** + +```json +{ + "tool": "wait_for_text", + "arguments": { + "text": "Server started", + "session_name": "dev", + "timeout": 30 + } +} +``` + +```{fastmcp-tool-input} pane_tools.wait_for_text +``` + +## Act + +```{fastmcp-tool} pane_tools.send_keys +``` + +**Use when** you need to type commands, press keys, or interact with a +terminal. This is the primary way to execute commands in tmux panes. + +**Avoid when** you need to run something and immediately capture the result — +send keys first, then use {ref}`capture-pane` or {ref}`wait-for-text`. + +**Side effects:** Sends keystrokes to the pane. If `enter` is true (default), +the command executes. + +**Example:** + +```json +{ + "tool": "send_keys", + "arguments": { + "keys": "npm start", + "session_name": "dev" + } +} +``` + +```{fastmcp-tool-input} pane_tools.send_keys +``` + +--- + +```{fastmcp-tool} pane_tools.set_pane_title +``` + +**Use when** you want to label a pane for identification. + +**Side effects:** Changes the pane title. + +```{fastmcp-tool-input} pane_tools.set_pane_title +``` + +--- + +```{fastmcp-tool} pane_tools.clear_pane +``` + +**Use when** you want a clean terminal before capturing output. + +**Side effects:** Clears the pane's visible content. + +```{fastmcp-tool-input} pane_tools.clear_pane +``` + +--- + +```{fastmcp-tool} pane_tools.resize_pane +``` + +**Use when** you need to adjust pane dimensions. + +**Side effects:** Changes pane size. May affect adjacent panes. + +```{fastmcp-tool-input} pane_tools.resize_pane +``` + +## Destroy + +```{fastmcp-tool} pane_tools.kill_pane +``` + +**Use when** you're done with a specific terminal and want to remove it +without affecting sibling panes. + +**Avoid when** you want to remove the entire window — use {ref}`kill-window`. + +**Side effects:** Destroys the pane. Not reversible. + +```{fastmcp-tool-input} pane_tools.kill_pane +``` diff --git a/docs/tools/sessions.md b/docs/tools/sessions.md new file mode 100644 index 00000000..90136059 --- /dev/null +++ b/docs/tools/sessions.md @@ -0,0 +1,122 @@ +# Sessions + +## Inspect + +```{fastmcp-tool} server_tools.list_sessions +``` + +**Use when** you need session names, IDs, or attached status before deciding +which session to target. + +**Avoid when** you need window or pane details — use {ref}`list-windows` or +{ref}`list-panes` instead. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "list_sessions", + "arguments": {} +} +``` + +```{fastmcp-tool-input} server_tools.list_sessions +``` + +--- + +```{fastmcp-tool} server_tools.get_server_info +``` + +**Use when** you need to verify the tmux server is running, check its PID, +or inspect server-level state before creating sessions. + +**Avoid when** you only need session names — use {ref}`list-sessions`. + +**Side effects:** None. Readonly. + +```{fastmcp-tool-input} server_tools.get_server_info +``` + +## Act + +```{fastmcp-tool} server_tools.create_session +``` + +**Use when** you need a new isolated workspace. Sessions are the top-level +container — create one before creating windows or panes. + +**Avoid when** a session with the target name already exists — check with +{ref}`list-sessions` first, or the command will fail. + +**Side effects:** Creates a new tmux session. Attaches if `attach` is true. + +**Example:** + +```json +{ + "tool": "create_session", + "arguments": { + "session_name": "dev" + } +} +``` + +```{fastmcp-tool-input} server_tools.create_session +``` + +--- + +```{fastmcp-tool} session_tools.rename_session +``` + +**Use when** a session name no longer reflects its purpose. + +**Side effects:** Renames the session. Existing references by old name will break. + +**Example:** + +```json +{ + "tool": "rename_session", + "arguments": { + "session_name": "old-name", + "new_name": "new-name" + } +} +``` + +```{fastmcp-tool-input} session_tools.rename_session +``` + +## Destroy + +```{fastmcp-tool} session_tools.kill_session +``` + +**Use when** you're done with a workspace and want to clean up. Kills all +windows and panes in the session. + +**Avoid when** you only want to close one window — use {ref}`kill-window`. + +**Side effects:** Destroys the session and all its contents. Not reversible. + +```{fastmcp-tool-input} session_tools.kill_session +``` + +--- + +```{fastmcp-tool} server_tools.kill_server +``` + +**Use when** you need to tear down the entire tmux server. This kills every +session, window, and pane. + +**Avoid when** you only need to remove one session — use {ref}`kill-session`. + +**Side effects:** Destroys everything. Not reversible. + +```{fastmcp-tool-input} server_tools.kill_server +``` diff --git a/docs/tools/windows.md b/docs/tools/windows.md new file mode 100644 index 00000000..165f7582 --- /dev/null +++ b/docs/tools/windows.md @@ -0,0 +1,140 @@ +# Windows + +## Inspect + +```{fastmcp-tool} session_tools.list_windows +``` + +**Use when** you need window names, indices, or layout metadata within a +session before selecting a window to work with. + +**Avoid when** you need pane-level detail — use {ref}`list-panes`. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "list_windows", + "arguments": { + "session_name": "dev" + } +} +``` + +```{fastmcp-tool-input} session_tools.list_windows +``` + +--- + +```{fastmcp-tool} window_tools.list_panes +``` + +**Use when** you need to discover which panes exist in a window before +sending keys or capturing output. + +**Side effects:** None. Readonly. + +```{fastmcp-tool-input} window_tools.list_panes +``` + +## Act + +```{fastmcp-tool} session_tools.create_window +``` + +**Use when** you need a new terminal workspace within an existing session. + +**Side effects:** Creates a new window. Attaches to it if `attach` is true. + +**Example:** + +```json +{ + "tool": "create_window", + "arguments": { + "session_name": "dev", + "window_name": "logs" + } +} +``` + +```{fastmcp-tool-input} session_tools.create_window +``` + +--- + +```{fastmcp-tool} window_tools.split_window +``` + +**Use when** you need side-by-side or stacked terminals within the same +window. + +**Side effects:** Creates a new pane by splitting an existing one. + +**Example:** + +```json +{ + "tool": "split_window", + "arguments": { + "session_name": "dev", + "direction": "horizontal" + } +} +``` + +```{fastmcp-tool-input} window_tools.split_window +``` + +--- + +```{fastmcp-tool} window_tools.rename_window +``` + +**Use when** a window name no longer reflects its purpose. + +**Side effects:** Renames the window. + +```{fastmcp-tool-input} window_tools.rename_window +``` + +--- + +```{fastmcp-tool} window_tools.select_layout +``` + +**Use when** you want to rearrange panes — `even-horizontal`, +`even-vertical`, `main-horizontal`, `main-vertical`, or `tiled`. + +**Side effects:** Rearranges all panes in the window. + +```{fastmcp-tool-input} window_tools.select_layout +``` + +--- + +```{fastmcp-tool} window_tools.resize_window +``` + +**Use when** you need to adjust the window dimensions. + +**Side effects:** Changes window size. + +```{fastmcp-tool-input} window_tools.resize_window +``` + +## Destroy + +```{fastmcp-tool} window_tools.kill_window +``` + +**Use when** you're done with a window and all its panes. + +**Avoid when** you only want to remove one pane — use {ref}`kill-pane`. + +**Side effects:** Destroys the window and all its panes. Not reversible. + +```{fastmcp-tool-input} window_tools.kill_window +``` diff --git a/pyproject.toml b/pyproject.toml index 34916a1f..b5e9f618 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,6 +134,14 @@ files = [ module = ["sphinx_fonts"] ignore_missing_imports = true +[[tool.mypy.overrides]] +module = ["fastmcp_autodoc", "docutils", "docutils.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["tests.docs.*"] +disable_error_code = ["untyped-decorator"] + [tool.coverage.run] branch = true parallel = true diff --git a/tests/docs/__init__.py b/tests/docs/__init__.py new file mode 100644 index 00000000..5a82d0c1 --- /dev/null +++ b/tests/docs/__init__.py @@ -0,0 +1 @@ +"""Tests for documentation extensions.""" diff --git a/tests/docs/_ext/__init__.py b/tests/docs/_ext/__init__.py new file mode 100644 index 00000000..7856f50a --- /dev/null +++ b/tests/docs/_ext/__init__.py @@ -0,0 +1 @@ +"""Tests for custom Sphinx extensions.""" diff --git a/tests/docs/_ext/conftest.py b/tests/docs/_ext/conftest.py new file mode 100644 index 00000000..e7547fa8 --- /dev/null +++ b/tests/docs/_ext/conftest.py @@ -0,0 +1,10 @@ +"""Fixtures and configuration for docs extension tests.""" + +from __future__ import annotations + +import pathlib +import sys + +docs_ext_path = pathlib.Path(__file__).parent.parent.parent.parent / "docs" / "_ext" +if str(docs_ext_path) not in sys.path: + sys.path.insert(0, str(docs_ext_path)) diff --git a/tests/docs/_ext/test_fastmcp_autodoc.py b/tests/docs/_ext/test_fastmcp_autodoc.py new file mode 100644 index 00000000..a81021b3 --- /dev/null +++ b/tests/docs/_ext/test_fastmcp_autodoc.py @@ -0,0 +1,518 @@ +"""Tests for fastmcp_autodoc Sphinx extension.""" + +from __future__ import annotations + +import typing as t + +import fastmcp_autodoc +import pytest + +# --------------------------------------------------------------------------- +# _parse_numpy_params +# --------------------------------------------------------------------------- + + +class ParseNumpyParamsFixture(t.NamedTuple): + """Test fixture for NumPy docstring parameter parsing.""" + + test_id: str + docstring: str + expected: dict[str, str] + + +PARSE_NUMPY_PARAMS_FIXTURES: list[ParseNumpyParamsFixture] = [ + ParseNumpyParamsFixture( + test_id="basic", + docstring=( + "Do something.\n\n" + "Parameters\n" + "----------\n" + "name : str\n" + " The name.\n" + "\n" + "Returns\n" + "-------\n" + "str\n" + ), + expected={"name": "The name."}, + ), + ParseNumpyParamsFixture( + test_id="multiple_params", + docstring=( + "Do something.\n\n" + "Parameters\n" + "----------\n" + "socket_name : str, optional\n" + " tmux socket name.\n" + "filters : dict or str, optional\n" + ' Django-style filters (e.g. ``{"key": "val"}``).\n' + "\n" + "Returns\n" + "-------\n" + ), + expected={ + "socket_name": "tmux socket name.", + "filters": 'Django-style filters (e.g. ``{"key": "val"}``).', + }, + ), + ParseNumpyParamsFixture( + test_id="multiline_description", + docstring=( + "Summary.\n\n" + "Parameters\n" + "----------\n" + "keys : str\n" + " The keys or text to send.\n" + " Can span multiple lines.\n" + "\n" + "Returns\n" + "-------\n" + ), + expected={"keys": "The keys or text to send. Can span multiple lines."}, + ), + ParseNumpyParamsFixture( + test_id="empty_docstring", + docstring="", + expected={}, + ), + ParseNumpyParamsFixture( + test_id="no_parameters_section", + docstring="Do something.\n\nReturns\n-------\nstr\n", + expected={}, + ), +] + + +@pytest.mark.parametrize( + PARSE_NUMPY_PARAMS_FIXTURES[0]._fields, + PARSE_NUMPY_PARAMS_FIXTURES, + ids=[f.test_id for f in PARSE_NUMPY_PARAMS_FIXTURES], +) +def test_parse_numpy_params( + test_id: str, + docstring: str, + expected: dict[str, str], +) -> None: + """_parse_numpy_params extracts parameter descriptions.""" + result = fastmcp_autodoc._parse_numpy_params(docstring) + assert result == expected + + +# --------------------------------------------------------------------------- +# _first_paragraph +# --------------------------------------------------------------------------- + + +class FirstParagraphFixture(t.NamedTuple): + """Test fixture for first paragraph extraction.""" + + test_id: str + docstring: str + expected: str + + +FIRST_PARAGRAPH_FIXTURES: list[FirstParagraphFixture] = [ + FirstParagraphFixture( + test_id="simple", + docstring="List all tmux sessions.", + expected="List all tmux sessions.", + ), + FirstParagraphFixture( + test_id="multiline_first_para", + docstring="Capture the visible contents\nof a tmux pane.\n\nMore detail.", + expected="Capture the visible contents of a tmux pane.", + ), + FirstParagraphFixture( + test_id="empty", + docstring="", + expected="", + ), +] + + +@pytest.mark.parametrize( + FIRST_PARAGRAPH_FIXTURES[0]._fields, + FIRST_PARAGRAPH_FIXTURES, + ids=[f.test_id for f in FIRST_PARAGRAPH_FIXTURES], +) +def test_first_paragraph( + test_id: str, + docstring: str, + expected: str, +) -> None: + """_first_paragraph extracts the first paragraph.""" + result = fastmcp_autodoc._first_paragraph(docstring) + assert result == expected + + +# --------------------------------------------------------------------------- +# _format_annotation +# --------------------------------------------------------------------------- + + +class FormatAnnotationFixture(t.NamedTuple): + """Test fixture for annotation formatting.""" + + test_id: str + annotation: t.Any + strip_none: bool + expected: str + + +FORMAT_ANNOTATION_FIXTURES: list[FormatAnnotationFixture] = [ + FormatAnnotationFixture( + test_id="string_with_none", + annotation="str | None", + strip_none=False, + expected="str | None", + ), + FormatAnnotationFixture( + test_id="string_strip_none", + annotation="str | None", + strip_none=True, + expected="str", + ), + FormatAnnotationFixture( + test_id="complex_strip_none", + annotation="dict[str, str] | str | None", + strip_none=True, + expected="dict[str, str] | str", + ), + FormatAnnotationFixture( + test_id="no_none_strip_noop", + annotation="str", + strip_none=True, + expected="str", + ), + FormatAnnotationFixture( + test_id="literal_cleanup", + annotation="t.Literal['server', 'session', 'window', 'pane'] | None", + strip_none=True, + expected="'server', 'session', 'window', 'pane'", + ), + FormatAnnotationFixture( + test_id="literal_cleanup_no_strip", + annotation="t.Literal['server', 'session', 'window', 'pane']", + strip_none=False, + expected="'server', 'session', 'window', 'pane'", + ), + FormatAnnotationFixture( + test_id="literal_no_prefix", + annotation="Literal['before', 'after']", + strip_none=False, + expected="'before', 'after'", + ), + FormatAnnotationFixture( + test_id="int_type", + annotation=int, + strip_none=False, + expected="int", + ), + FormatAnnotationFixture( + test_id="empty", + annotation="", + strip_none=False, + expected="", + ), +] + + +@pytest.mark.parametrize( + FORMAT_ANNOTATION_FIXTURES[0]._fields, + FORMAT_ANNOTATION_FIXTURES, + ids=[f.test_id for f in FORMAT_ANNOTATION_FIXTURES], +) +def test_format_annotation( + test_id: str, + annotation: t.Any, + strip_none: bool, + expected: str, +) -> None: + """_format_annotation formats type annotations correctly.""" + import inspect + + if annotation == "": + annotation = inspect.Parameter.empty + expected = "" + + result = fastmcp_autodoc._format_annotation(annotation, strip_none=strip_none) + assert result == expected + + +# --------------------------------------------------------------------------- +# _ToolCollector +# --------------------------------------------------------------------------- + + +def test_tool_collector_captures_registrations() -> None: + """_ToolCollector captures tool metadata from register() calls.""" + collector = fastmcp_autodoc._ToolCollector() + collector._current_module = "server_tools" + + @collector.tool( + title="List Sessions", + annotations={"readOnlyHint": True}, + tags={"readonly"}, + ) + def list_sessions(socket_name: str | None = None) -> list[str]: + """List all tmux sessions. + + Parameters + ---------- + socket_name : str, optional + tmux socket name. + + Returns + ------- + list[str] + """ + return [] + + assert len(collector.tools) == 1 + tool = collector.tools[0] + assert tool.name == "list_sessions" + assert tool.title == "List Sessions" + assert tool.safety == "readonly" + assert tool.area == "sessions" + assert tool.module_name == "server_tools" + assert len(tool.params) == 1 + assert tool.params[0].name == "socket_name" + assert tool.params[0].required is False + assert tool.params[0].description == "tmux socket name." + + +def test_tool_collector_safety_tiers() -> None: + """_ToolCollector correctly determines safety tier from tags.""" + collector = fastmcp_autodoc._ToolCollector() + collector._current_module = "test_tools" + + @collector.tool(tags={"readonly"}) + def read_tool() -> str: + """Read.""" + return "" + + @collector.tool(tags={"mutating"}) + def write_tool() -> str: + """Write.""" + return "" + + @collector.tool(tags={"destructive"}) + def destroy_tool() -> str: + """Destroy.""" + return "" + + assert collector.tools[0].safety == "readonly" + assert collector.tools[1].safety == "mutating" + assert collector.tools[2].safety == "destructive" + + +def test_tool_collector_strips_none_for_optional_params() -> None: + """Optional parameters should have | None stripped from type.""" + collector = fastmcp_autodoc._ToolCollector() + collector._current_module = "test_tools" + + @collector.tool(tags={"readonly"}) + def my_tool( + required_param: str, + optional_param: str | None = None, + ) -> str: + """Test. + + Parameters + ---------- + required_param : str + Required. + optional_param : str, optional + Optional. + + Returns + ------- + str + """ + return "" + + tool = collector.tools[0] + required = next(p for p in tool.params if p.name == "required_param") + optional = next(p for p in tool.params if p.name == "optional_param") + + assert required.required is True + assert "None" not in required.type_str or required.type_str == "str" + + assert optional.required is False + # | None should be stripped for optional params + assert optional.type_str == "str" + + +# --------------------------------------------------------------------------- +# _make_table +# --------------------------------------------------------------------------- + + +def test_make_table_structure() -> None: + """_make_table creates proper docutils table node hierarchy.""" + from docutils import nodes + + table = fastmcp_autodoc._make_table( + headers=["Name", "Type"], + rows=[["foo", "str"], ["bar", "int"]], + ) + + assert isinstance(table, nodes.table) + tgroup = table[0] + assert isinstance(tgroup, nodes.tgroup) + assert tgroup["cols"] == 2 + + # Header + thead = tgroup.children[2] # after 2 colspecs + assert isinstance(thead, nodes.thead) + header_row = thead[0] + assert len(header_row) == 2 + + # Body + tbody = tgroup.children[3] + assert isinstance(tbody, nodes.tbody) + assert len(tbody) == 2 # 2 data rows + + +def test_make_table_with_node_cells() -> None: + """_make_table handles Node objects as cell values.""" + from docutils import nodes + + literal = nodes.literal("", "code") + para = nodes.paragraph("", "") + para += literal + + table = fastmcp_autodoc._make_table( + headers=["Col"], + rows=[[para]], + ) + + # Just check the table built without error + assert isinstance(table, nodes.table) + + +# --------------------------------------------------------------------------- +# _safety_badge +# --------------------------------------------------------------------------- + + +def test_make_type_cell_splits_union() -> None: + """_make_type_cell splits union types into comma-separated literals.""" + from docutils import nodes + + para = fastmcp_autodoc._make_type_cell("dict[str, str] | str") + literals = [c for c in para.children if isinstance(c, nodes.literal)] + texts = [c.astext() for c in literals] + assert texts == ["dict[str, str]", "str"] + + # Separators should be Text nodes with ", " + text_nodes = [c for c in para.children if isinstance(c, nodes.Text)] + assert any(", " in c.astext() for c in text_nodes) + + +def test_make_type_cell_splits_literal_values() -> None: + """_make_type_cell splits quoted literal values into separate literals.""" + from docutils import nodes + + para = fastmcp_autodoc._make_type_cell("'server', 'session', 'window'") + literals = [c for c in para.children if isinstance(c, nodes.literal)] + texts = [c.astext() for c in literals] + assert texts == ["'server'", "'session'", "'window'"] + + +def test_make_type_cell_single_type() -> None: + """_make_type_cell handles single types without splitting.""" + from docutils import nodes + + para = fastmcp_autodoc._make_type_cell("str") + literals = [c for c in para.children if isinstance(c, nodes.literal)] + assert len(literals) == 1 + assert literals[0].astext() == "str" + + +def test_safety_badge_classes() -> None: + """_safety_badge creates inline nodes with correct CSS classes.""" + badge = fastmcp_autodoc._safety_badge("readonly") + assert "sd-bg-success" in badge["classes"] + + badge = fastmcp_autodoc._safety_badge("mutating") + assert "sd-bg-warning" in badge["classes"] + + badge = fastmcp_autodoc._safety_badge("destructive") + assert "sd-bg-danger" in badge["classes"] + + +# --------------------------------------------------------------------------- +# Integration: collect real tools +# --------------------------------------------------------------------------- + + +def test_collect_real_tools() -> None: + """Collecting tools from libtmux_mcp source produces expected results.""" + collector = fastmcp_autodoc._ToolCollector() + + tool_modules = [ + "server_tools", + "session_tools", + "window_tools", + "pane_tools", + "option_tools", + "env_tools", + ] + + import importlib + + for mod_name in tool_modules: + collector._current_module = mod_name + mod = importlib.import_module(f"libtmux_mcp.tools.{mod_name}") + mod.register(collector) + + tools = {t.name: t for t in collector.tools} + + # Should have all expected tools + assert "list_sessions" in tools + assert "capture_pane" in tools + assert "send_keys" in tools + assert "kill_server" in tools + assert "show_option" in tools + assert "show_environment" in tools + + # Safety tiers should be correct + assert tools["list_sessions"].safety == "readonly" + assert tools["send_keys"].safety == "mutating" + assert tools["kill_server"].safety == "destructive" + + # Parameters should be extracted + ls = tools["list_sessions"] + param_names = [p.name for p in ls.params] + assert "socket_name" in param_names + assert "filters" in param_names + + # Descriptions should be parsed from docstrings + socket_param = next(p for p in ls.params if p.name == "socket_name") + assert "socket" in socket_param.description.lower() + + # Optional params should have | None stripped + assert socket_param.type_str == "str" + assert socket_param.required is False + + +def test_collect_real_tools_total_count() -> None: + """All 27 tools should be collected.""" + collector = fastmcp_autodoc._ToolCollector() + + import importlib + + for mod_name in [ + "server_tools", + "session_tools", + "window_tools", + "pane_tools", + "option_tools", + "env_tools", + ]: + collector._current_module = mod_name + mod = importlib.import_module(f"libtmux_mcp.tools.{mod_name}") + mod.register(collector) + + assert len(collector.tools) == 27