From cab219026931e9b5bcf231e9c28838d752cbd7ae Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 10:30:09 -0500 Subject: [PATCH 01/47] mcp(feat[foundation]): Add MCP server foundation with FastMCP why: AI agents need programmatic tmux control via MCP protocol. what: - Add fastmcp optional dependency in pyproject.toml - Add libtmux-mcp entry point script - Create _utils.py with server caching, object resolvers, serializers, and @handle_tool_errors decorator - Create server.py with FastMCP instance and registration - Add __init__.py and __main__.py entry points --- pyproject.toml | 8 + src/libtmux/mcp/__init__.py | 10 + src/libtmux/mcp/__main__.py | 7 + src/libtmux/mcp/_utils.py | 368 ++++++++++++ src/libtmux/mcp/server.py | 34 ++ uv.lock | 1045 ++++++++++++++++++++++++++++++++++- 6 files changed, 1471 insertions(+), 1 deletion(-) create mode 100644 src/libtmux/mcp/__init__.py create mode 100644 src/libtmux/mcp/__main__.py create mode 100644 src/libtmux/mcp/_utils.py create mode 100644 src/libtmux/mcp/server.py diff --git a/pyproject.toml b/pyproject.toml index 84c73e066..9e30e7784 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,14 @@ lint = [ "mypy", ] +[project.optional-dependencies] +mcp = [ + "fastmcp>=2.3.0; python_version >= '3.10'", +] + +[project.scripts] +libtmux-mcp = "libtmux.mcp:main" + [project.entry-points.pytest11] libtmux = "libtmux.pytest_plugin" diff --git a/src/libtmux/mcp/__init__.py b/src/libtmux/mcp/__init__.py new file mode 100644 index 000000000..a2bf2d914 --- /dev/null +++ b/src/libtmux/mcp/__init__.py @@ -0,0 +1,10 @@ +"""libtmux MCP server - programmatic tmux control for AI agents.""" + +from __future__ import annotations + + +def main() -> None: + """Entry point for the libtmux MCP server.""" + from libtmux.mcp.server import run_server + + run_server() diff --git a/src/libtmux/mcp/__main__.py b/src/libtmux/mcp/__main__.py new file mode 100644 index 000000000..e2c39886d --- /dev/null +++ b/src/libtmux/mcp/__main__.py @@ -0,0 +1,7 @@ +"""Support ``python -m libtmux.mcp``.""" + +from __future__ import annotations + +from libtmux.mcp import main + +main() diff --git a/src/libtmux/mcp/_utils.py b/src/libtmux/mcp/_utils.py new file mode 100644 index 000000000..f6a5135bc --- /dev/null +++ b/src/libtmux/mcp/_utils.py @@ -0,0 +1,368 @@ +"""Shared utilities for libtmux MCP server. + +Provides server caching, object resolution, serialization, and error handling +for all MCP tool functions. +""" + +from __future__ import annotations + +import functools +import logging +import os +import typing as t + +from libtmux import exc +from libtmux.server import Server + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + from libtmux.session import Session + from libtmux.window import Window + +logger = logging.getLogger(__name__) + +_server_cache: dict[tuple[str | None, str | None], Server] = {} + + +def _get_server( + socket_name: str | None = None, + socket_path: str | None = None, +) -> Server: + """Get or create a cached Server instance. + + Parameters + ---------- + socket_name : str, optional + tmux socket name (-L). Falls back to LIBTMUX_SOCKET env var. + socket_path : str, optional + tmux socket path (-S). Falls back to LIBTMUX_SOCKET_PATH env var. + + Returns + ------- + Server + A cached libtmux Server instance. + """ + if socket_name is None: + socket_name = os.environ.get("LIBTMUX_SOCKET") + if socket_path is None: + socket_path = os.environ.get("LIBTMUX_SOCKET_PATH") + + tmux_bin = os.environ.get("LIBTMUX_TMUX_BIN") + + cache_key = (socket_name, socket_path) + if cache_key not in _server_cache: + kwargs: dict[str, t.Any] = {} + if socket_name is not None: + kwargs["socket_name"] = socket_name + if socket_path is not None: + kwargs["socket_path"] = socket_path + if tmux_bin is not None: + kwargs["tmux_bin"] = tmux_bin + _server_cache[cache_key] = Server(**kwargs) + + return _server_cache[cache_key] + + +def _resolve_session( + server: Server, + session_name: str | None = None, + session_id: str | None = None, +) -> Session: + """Resolve a session by name or ID. + + Parameters + ---------- + server : Server + The tmux server. + session_name : str, optional + Session name to look up. + session_id : str, optional + Session ID (e.g. '$1') to look up. + + Returns + ------- + Session + + Raises + ------ + exc.TmuxObjectDoesNotExist + If no matching session is found. + """ + if session_id is not None: + session = server.sessions.get(session_id=session_id, default=None) + if session is None: + raise exc.TmuxObjectDoesNotExist( + obj_key="session_id", + obj_id=session_id, + list_cmd="list-sessions", + ) + return session + + if session_name is not None: + session = server.sessions.get(session_name=session_name, default=None) + if session is None: + raise exc.TmuxObjectDoesNotExist( + obj_key="session_name", + obj_id=session_name, + list_cmd="list-sessions", + ) + return session + + sessions = server.sessions + if not sessions: + raise exc.TmuxObjectDoesNotExist( + obj_key="session", + obj_id="(any)", + list_cmd="list-sessions", + ) + return sessions[0] + + +def _resolve_window( + server: Server, + session: Session | None = None, + window_id: str | None = None, + window_index: str | None = None, + session_name: str | None = None, + session_id: str | None = None, +) -> Window: + """Resolve a window by ID, index, or default. + + Parameters + ---------- + server : Server + The tmux server. + session : Session, optional + Session to search within. + window_id : str, optional + Window ID (e.g. '@1'). + window_index : str, optional + Window index within the session. + session_name : str, optional + Session name for resolution. + session_id : str, optional + Session ID for resolution. + + Returns + ------- + Window + + Raises + ------ + exc.TmuxObjectDoesNotExist + If no matching window is found. + """ + if window_id is not None: + window = server.windows.get(window_id=window_id, default=None) + if window is None: + raise exc.TmuxObjectDoesNotExist( + obj_key="window_id", + obj_id=window_id, + list_cmd="list-windows", + ) + return window + + if session is None: + session = _resolve_session( + server, + session_name=session_name, + session_id=session_id, + ) + + if window_index is not None: + window = session.windows.get(window_index=window_index, default=None) + if window is None: + raise exc.TmuxObjectDoesNotExist( + obj_key="window_index", + obj_id=window_index, + list_cmd="list-windows", + ) + return window + + windows = session.windows + if not windows: + raise exc.NoWindowsExist() + return windows[0] + + +def _resolve_pane( + server: Server, + pane_id: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + window_index: str | None = None, + pane_index: str | None = None, +) -> Pane: + """Resolve a pane by ID or hierarchical targeting. + + Parameters + ---------- + server : Server + The tmux server. + pane_id : str, optional + Pane ID (e.g. '%%1'). Globally unique within a server. + session_name : str, optional + Session name for hierarchical resolution. + session_id : str, optional + Session ID for hierarchical resolution. + window_id : str, optional + Window ID for hierarchical resolution. + window_index : str, optional + Window index for hierarchical resolution. + pane_index : str, optional + Pane index within the window. + + Returns + ------- + Pane + + Raises + ------ + exc.TmuxObjectDoesNotExist + If no matching pane is found. + """ + if pane_id is not None: + pane = server.panes.get(pane_id=pane_id, default=None) + if pane is None: + raise exc.PaneNotFound(pane_id=pane_id) + return pane + + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + session_id=session_id, + ) + + if pane_index is not None: + pane = window.panes.get(pane_index=pane_index, default=None) + if pane is None: + raise exc.PaneNotFound(pane_id=f"index:{pane_index}") + return pane + + panes = window.panes + if not panes: + raise exc.PaneNotFound() + return panes[0] + + +def _serialize_session(session: Session) -> dict[str, t.Any]: + """Serialize a Session to a JSON-compatible dict. + + Parameters + ---------- + session : Session + The session to serialize. + + Returns + ------- + dict + Session data including id, name, window count, dimensions. + """ + return { + "session_id": session.session_id, + "session_name": session.session_name, + "window_count": len(session.windows), + "session_attached": getattr(session, "session_attached", None), + "session_created": getattr(session, "session_created", None), + } + + +def _serialize_window(window: Window) -> dict[str, t.Any]: + """Serialize a Window to a JSON-compatible dict. + + Parameters + ---------- + window : Window + The window to serialize. + + Returns + ------- + dict + Window data including id, name, index, pane count, layout. + """ + return { + "window_id": window.window_id, + "window_name": window.window_name, + "window_index": window.window_index, + "session_id": window.session_id, + "session_name": getattr(window, "session_name", None), + "pane_count": len(window.panes), + "window_layout": getattr(window, "window_layout", None), + "window_active": getattr(window, "window_active", None), + "window_width": getattr(window, "window_width", None), + "window_height": getattr(window, "window_height", None), + } + + +def _serialize_pane(pane: Pane) -> dict[str, t.Any]: + """Serialize a Pane to a JSON-compatible dict. + + Parameters + ---------- + pane : Pane + The pane to serialize. + + Returns + ------- + dict + Pane data including id, dimensions, current command, title. + """ + return { + "pane_id": pane.pane_id, + "pane_index": getattr(pane, "pane_index", None), + "pane_width": getattr(pane, "pane_width", None), + "pane_height": getattr(pane, "pane_height", None), + "pane_current_command": getattr(pane, "pane_current_command", None), + "pane_current_path": getattr(pane, "pane_current_path", None), + "pane_pid": getattr(pane, "pane_pid", None), + "pane_title": getattr(pane, "pane_title", None), + "pane_active": getattr(pane, "pane_active", None), + "window_id": pane.window_id, + "session_id": pane.session_id, + } + + +P = t.ParamSpec("P") +R = t.TypeVar("R") + + +def handle_tool_errors( + fn: t.Callable[P, R], +) -> t.Callable[P, R]: + """Decorate MCP tool functions with standardized error handling. + + Catches libtmux exceptions and re-raises as ``ToolError`` so that + MCP responses have ``isError=True`` with a descriptive message. + """ + from fastmcp.exceptions import ToolError + + @functools.wraps(fn) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + try: + return fn(*args, **kwargs) + except ToolError: + raise + except exc.TmuxCommandNotFound as e: + msg = "tmux binary not found. Ensure tmux is installed and in PATH." + raise ToolError(msg) from e + except exc.TmuxSessionExists as e: + raise ToolError(str(e)) from e + except exc.BadSessionName as e: + raise ToolError(str(e)) from e + except exc.TmuxObjectDoesNotExist as e: + msg = f"Object not found: {e}" + raise ToolError(msg) from e + except exc.PaneNotFound as e: + msg = f"Pane not found: {e}" + raise ToolError(msg) from e + except exc.LibTmuxException as e: + msg = f"tmux error: {e}" + raise ToolError(msg) from e + except Exception as e: + msg = f"Unexpected error: {type(e).__name__}: {e}" + raise ToolError(msg) from e + + return wrapper diff --git a/src/libtmux/mcp/server.py b/src/libtmux/mcp/server.py new file mode 100644 index 000000000..56bc78ca8 --- /dev/null +++ b/src/libtmux/mcp/server.py @@ -0,0 +1,34 @@ +"""FastMCP server instance for libtmux. + +Creates and configures the MCP server with all tools and resources. +""" + +from __future__ import annotations + +from fastmcp import FastMCP + +mcp = FastMCP( + name="libtmux", + instructions=( + "libtmux MCP server for programmatic tmux control. " + "Use pane_id (e.g. '%%1') as the preferred targeting method - " + "it is globally unique within a tmux server. " + "Use send_keys to execute commands and capture_pane to read output. " + "All tools accept an optional socket_name parameter for multi-server support." + ), +) + + +def _register_all() -> None: + """Register all tools and resources with the MCP server.""" + from libtmux.mcp.resources import register_resources + from libtmux.mcp.tools import register_tools + + register_tools(mcp) + register_resources(mcp) + + +def run_server() -> None: + """Run the MCP server.""" + _register_all() + mcp.run() diff --git a/uv.lock b/uv.lock index ced740f26..da453bc51 100644 --- a/uv.lock +++ b/uv.lock @@ -19,6 +19,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, ] +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, +] + [[package]] name = "alabaster" version = "1.0.0" @@ -28,6 +40,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.12.1" @@ -42,6 +63,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "authlib" +version = "1.6.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, +] + [[package]] name = "babel" version = "2.18.0" @@ -51,6 +93,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -64,6 +124,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] +[[package]] +name = "cachetools" +version = "7.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/5c/3b882b82e9af737906539a2eafb62f96a229f1fa80255bede0c7b554cbc4/cachetools-7.0.3.tar.gz", hash = "sha256:8c246313b95849964e54a909c03b327a87ab0428b068fac10da7b105ca275ef6", size = 37187, upload-time = "2026-03-05T21:00:57.918Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/4a/573185481c50a8841331f54ddae44e4a3469c46aa0b397731c53a004369a/cachetools-7.0.3-py3-none-any.whl", hash = "sha256:c128ffca156eef344c25fcd08a96a5952803786fa33097f5f2d49edf76f79d53", size = 13907, upload-time = "2026-03-05T21:00:56.486Z" }, +] + +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/80/ea4ead0c5d52a9828692e7df20f0eafe8d26e671ce4883a0a146bb91049e/caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619", size = 36836, upload-time = "2025-12-26T15:22:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/36715c97c873649d1029001578f901b50250916295e3dddf20c865438865/caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241", size = 79695, upload-time = "2025-12-26T15:22:18.818Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/07080ecb1adb55a02cbd8ec0126aa8e43af343ffabb6a71125b42670e9a1/caio-0.9.25-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:bf61d7d0c4fd10ffdd98ca47f7e8db4d7408e74649ffaf4bef40b029ada3c21b", size = 79457, upload-time = "2026-03-04T22:08:16.024Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/dd55757bb671eb4c376e006c04e83beb413486821f517792ea603ef216e9/caio-0.9.25-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:ab52e5b643f8bbd64a0605d9412796cd3464cb8ca88593b13e95a0f0b10508ae", size = 77705, upload-time = "2026-03-04T22:08:17.202Z" }, + { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, + { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" }, + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -73,6 +171,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.6" @@ -330,6 +510,101 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/7a/3c3623755561c7f283dd769470e99ae36c46810bf3b3f264d69006f6c97a/cyclopts-4.8.0.tar.gz", hash = "sha256:92cc292d18d8be372e58d8bce1aa966d30f819a5fb3fee02bd2ad4a6bb403f29", size = 164066, upload-time = "2026-03-07T19:39:18.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/01/6ec7210775ea5e4989a10d89eda6c5ea7ff06caa614231ad533d74fecac8/cyclopts-4.8.0-py3-none-any.whl", hash = "sha256:ef353da05fec36587d4ebce7a6e4b27515d775d184a23bab4b01426f93ddc8d4", size = 201948, upload-time = "2026-03-07T19:39:19.307Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + [[package]] name = "docutils" version = "0.21.2" @@ -339,12 +614,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -360,6 +648,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] +[[package]] +name = "fastmcp" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "pydantic", extra = ["email"] }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "uncalled-for" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/70/862026c4589441f86ad3108f05bfb2f781c6b322ad60a982f40b303b47d7/fastmcp-3.1.0.tar.gz", hash = "sha256:e25264794c734b9977502a51466961eeecff92a0c2f3b49c40c070993628d6d0", size = 17347083, upload-time = "2026-03-03T02:43:11.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/07/516f5b20d88932e5a466c2216b628e5358a71b3a9f522215607c3281de05/fastmcp-3.1.0-py3-none-any.whl", hash = "sha256:b1f73b56fd3b0cb2bd9e2a144fc650d5cc31587ed129d996db7710e464ae8010", size = 633749, upload-time = "2026-03-03T02:43:09.06Z" }, +] + [[package]] name = "furo" version = "2025.12.19" @@ -400,6 +720,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -418,6 +775,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -427,6 +796,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/7b/c3081ff1af947915503121c649f26a778e1a2101fd525f74aef997d75b7e/jaraco_context-6.1.1.tar.gz", hash = "sha256:bc046b2dc94f1e5532bd02402684414575cc11f565d929b6563125deb0a6e581", size = 15832, upload-time = "2026-03-07T15:46:04.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/49/c152890d49102b280ecf86ba5f80a8c111c3a155dafa3bd24aeb64fde9e1/jaraco_context-6.1.1-py3-none-any.whl", hash = "sha256:0df6a0287258f3e364072c3e40d5411b20cafa30cb28c4839d24319cecf9f808", size = 7005, upload-time = "2026-03-07T15:46:03.515Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -439,6 +853,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/8a/7e6102f2b8bdc6705a9eb5294f8f6f9ccd3a8420e8e8e19671d1dd773251/jsonschema_path-0.4.5.tar.gz", hash = "sha256:c6cd7d577ae290c7defd4f4029e86fdb248ca1bd41a07557795b3c95e5144918", size = 15113, upload-time = "2026-03-03T09:56:46.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/d5/4e96c44f6c1ea3d812cf5391d81a4f5abaa540abf8d04ecd7f66e0ed11df/jsonschema_path-0.4.5-py3-none-any.whl", hash = "sha256:7d77a2c3f3ec569a40efe5c5f942c44c1af2a6f96fe0866794c9ef5b8f87fd65", size = 19368, upload-time = "2026-03-03T09:56:45.39Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "librt" version = "0.8.1" @@ -529,6 +1011,11 @@ name = "libtmux" version = "0.55.0" source = { editable = "." } +[package.optional-dependencies] +mcp = [ + { name = "fastmcp" }, +] + [package.dev-dependencies] coverage = [ { name = "codecov" }, @@ -595,6 +1082,8 @@ testing = [ ] [package.metadata] +requires-dist = [{ name = "fastmcp", marker = "python_full_version >= '3.10' and extra == 'mcp'", specifier = ">=2.3.0" }] +provides-extras = ["mcp"] [package.metadata.requires-dev] coverage = [ @@ -781,6 +1270,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + [[package]] name = "mdit-py-plugins" version = "0.5.0" @@ -803,6 +1317,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + [[package]] name = "mypy" version = "1.19.1" @@ -899,6 +1422,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" }, ] +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -908,6 +1456,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pathable" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" }, +] + [[package]] name = "pathspec" version = "1.0.4" @@ -917,6 +1474,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -926,6 +1492,192 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "py-key-value-aio" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -935,6 +1687,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -1018,6 +1793,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1082,6 +1906,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -1097,6 +1935,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + [[package]] name = "roman-numerals" version = "4.1.0" @@ -1118,6 +1983,128 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/2c/daca29684cbe9fd4bc711f8246da3c10adca1ccc4d24436b17572eb2590e/roman_numerals_py-4.1.0-py3-none-any.whl", hash = "sha256:553114c1167141c1283a51743759723ecd05604a1b6b507225e91dc1a6df0780", size = 4547, upload-time = "2025-12-17T18:25:40.136Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + [[package]] name = "ruff" version = "0.15.6" @@ -1143,6 +2130,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "snowballstemmer" version = "3.0.1" @@ -1415,6 +2415,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/55/ab40a0d1378ee5c859590a633052cf1d0a1f8435af87558a9f7cd576601a/sphinxext_rediraffe-0.3.0-py3-none-any.whl", hash = "sha256:f4220beafa99c99177488276b8e4fcf61fbeeec4253c1e4aae841a18c475330c", size = 7194, upload-time = "2025-09-28T15:31:52.388Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" }, +] + [[package]] name = "starlette" version = "0.52.1" @@ -1491,6 +2504,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "uc-micro-py" version = "2.0.0" @@ -1500,6 +2525,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, ] +[[package]] +name = "uncalled-for" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/7c/b5b7d8136f872e3f13b0584e576886de0489d7213a12de6bebf29ff6ebfc/uncalled_for-0.2.0.tar.gz", hash = "sha256:b4f8fdbcec328c5a113807d653e041c5094473dd4afa7c34599ace69ccb7e69f", size = 49488, upload-time = "2026-02-27T17:40:58.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl", hash = "sha256:2c0bd338faff5f930918f79e7eb9ff48290df2cb05fcc0b40a7f334e55d4d85f", size = 11351, upload-time = "2026-02-27T17:40:56.804Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -1725,3 +2759,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From 45e23d327b7f898b550c7523d9a405654159dc76 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 10:30:16 -0500 Subject: [PATCH 02/47] mcp(feat[tools]): Add 25 MCP tools for tmux control why: Provide comprehensive tool coverage for AI agents to manage tmux. what: - server_tools: list_sessions, create_session, kill_server, get_server_info - session_tools: list_windows, create_window, rename_session, kill_session - window_tools: list_panes, split_window, rename_window, kill_window, select_layout, resize_window - pane_tools: send_keys, capture_pane, resize_pane, kill_pane, set_pane_title, get_pane_info, clear_pane - option_tools: show_option, set_option - env_tools: show_environment, set_environment --- src/libtmux/mcp/tools/__init__.py | 27 +++ src/libtmux/mcp/tools/env_tools.py | 101 ++++++++ src/libtmux/mcp/tools/option_tools.py | 121 ++++++++++ src/libtmux/mcp/tools/pane_tools.py | 319 ++++++++++++++++++++++++ src/libtmux/mcp/tools/server_tools.py | 144 +++++++++++ src/libtmux/mcp/tools/session_tools.py | 166 +++++++++++++ src/libtmux/mcp/tools/window_tools.py | 320 +++++++++++++++++++++++++ 7 files changed, 1198 insertions(+) create mode 100644 src/libtmux/mcp/tools/__init__.py create mode 100644 src/libtmux/mcp/tools/env_tools.py create mode 100644 src/libtmux/mcp/tools/option_tools.py create mode 100644 src/libtmux/mcp/tools/pane_tools.py create mode 100644 src/libtmux/mcp/tools/server_tools.py create mode 100644 src/libtmux/mcp/tools/session_tools.py create mode 100644 src/libtmux/mcp/tools/window_tools.py diff --git a/src/libtmux/mcp/tools/__init__.py b/src/libtmux/mcp/tools/__init__.py new file mode 100644 index 000000000..5f835f609 --- /dev/null +++ b/src/libtmux/mcp/tools/__init__.py @@ -0,0 +1,27 @@ +"""MCP tool registration for libtmux.""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + + +def register_tools(mcp: FastMCP) -> None: + """Register all tool modules with the FastMCP instance.""" + from libtmux.mcp.tools import ( + env_tools, + option_tools, + pane_tools, + server_tools, + session_tools, + window_tools, + ) + + server_tools.register(mcp) + session_tools.register(mcp) + window_tools.register(mcp) + pane_tools.register(mcp) + option_tools.register(mcp) + env_tools.register(mcp) diff --git a/src/libtmux/mcp/tools/env_tools.py b/src/libtmux/mcp/tools/env_tools.py new file mode 100644 index 000000000..7d3c7ddc6 --- /dev/null +++ b/src/libtmux/mcp/tools/env_tools.py @@ -0,0 +1,101 @@ +"""MCP tools for tmux environment variable management.""" + +from __future__ import annotations + +import json +import typing as t + +from libtmux.mcp._utils import ( + _get_server, + _resolve_session, + handle_tool_errors, +) + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + + +@handle_tool_errors +def show_environment( + session_name: str | None = None, + session_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Show tmux environment variables. + + Parameters + ---------- + session_name : str, optional + Session name to query environment for. + session_id : str, optional + Session ID to query environment for. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + JSON dict of environment variables. + """ + server = _get_server(socket_name=socket_name) + + if session_name is not None or session_id is not None: + session = _resolve_session( + server, + session_name=session_name, + session_id=session_id, + ) + env_dict = session.show_environment() + else: + env_dict = server.show_environment() + + return json.dumps(env_dict) + + +@handle_tool_errors +def set_environment( + name: str, + value: str, + session_name: str | None = None, + session_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Set a tmux environment variable. + + Parameters + ---------- + name : str + Environment variable name. + value : str + Environment variable value. + session_name : str, optional + Session name to set environment for. + session_id : str, optional + Session ID to set environment for. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + JSON confirming the variable was set. + """ + server = _get_server(socket_name=socket_name) + + if session_name is not None or session_id is not None: + session = _resolve_session( + server, + session_name=session_name, + session_id=session_id, + ) + session.set_environment(name, value) + else: + server.set_environment(name, value) + + return json.dumps({"name": name, "value": value, "status": "set"}) + + +def register(mcp: FastMCP) -> None: + """Register environment tools with the MCP instance.""" + mcp.tool(annotations={"readOnlyHint": True})(show_environment) + mcp.tool(annotations={"destructiveHint": False})(set_environment) diff --git a/src/libtmux/mcp/tools/option_tools.py b/src/libtmux/mcp/tools/option_tools.py new file mode 100644 index 000000000..6261f0930 --- /dev/null +++ b/src/libtmux/mcp/tools/option_tools.py @@ -0,0 +1,121 @@ +"""MCP tools for tmux option management.""" + +from __future__ import annotations + +import json +import typing as t + +from libtmux.constants import OptionScope +from libtmux.mcp._utils import ( + _get_server, + _resolve_pane, + _resolve_session, + _resolve_window, + handle_tool_errors, +) + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + + from libtmux.options import OptionsMixin + +_SCOPE_MAP: dict[str, OptionScope] = { + "server": OptionScope.Server, + "session": OptionScope.Session, + "window": OptionScope.Window, + "pane": OptionScope.Pane, +} + + +def _resolve_option_target( + socket_name: str | None, + scope: str | None, + target: str | None, +) -> tuple[OptionsMixin, OptionScope | None]: + """Resolve the target object and scope for option operations.""" + server = _get_server(socket_name=socket_name) + opt_scope = _SCOPE_MAP.get(scope) if scope is not None else None + + if target is not None and opt_scope is not None: + if opt_scope == OptionScope.Session: + return _resolve_session(server, session_name=target), opt_scope + if opt_scope == OptionScope.Window: + return _resolve_window(server, window_id=target), opt_scope + if opt_scope == OptionScope.Pane: + return _resolve_pane(server, pane_id=target), opt_scope + return server, opt_scope + + +@handle_tool_errors +def show_option( + option: str, + scope: str | None = None, + target: str | None = None, + global_: bool = False, + socket_name: str | None = None, +) -> str: + """Show a tmux option value. + + Parameters + ---------- + option : str + The tmux option name to query. + scope : str, optional + Option scope: "server", "session", "window", or "pane". + target : str, optional + Target session, window, or pane identifier. + global_ : bool + Whether to query the global option. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + JSON with the option name and its value. + """ + obj, opt_scope = _resolve_option_target(socket_name, scope, target) + value = obj.show_option(option, global_=global_, scope=opt_scope) + return json.dumps({"option": option, "value": value}) + + +@handle_tool_errors +def set_option( + option: str, + value: str, + scope: str | None = None, + target: str | None = None, + global_: bool = False, + socket_name: str | None = None, +) -> str: + """Set a tmux option value. + + Parameters + ---------- + option : str + The tmux option name to set. + value : str + The value to set. + scope : str, optional + Option scope: "server", "session", "window", or "pane". + target : str, optional + Target session, window, or pane identifier. + global_ : bool + Whether to set the global option. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + JSON confirming the option was set. + """ + obj, opt_scope = _resolve_option_target(socket_name, scope, target) + obj.set_option(option, value, global_=global_, scope=opt_scope) + return json.dumps({"option": option, "value": value, "status": "set"}) + + +def register(mcp: FastMCP) -> None: + """Register option tools with the MCP instance.""" + mcp.tool(annotations={"readOnlyHint": True})(show_option) + mcp.tool(annotations={"destructiveHint": False})(set_option) diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py new file mode 100644 index 000000000..3b9e4a17b --- /dev/null +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -0,0 +1,319 @@ +"""MCP tools for tmux pane operations.""" + +from __future__ import annotations + +import json +import typing as t + +from libtmux.mcp._utils import ( + _get_server, + _resolve_pane, + _serialize_pane, + handle_tool_errors, +) + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + + +@handle_tool_errors +def send_keys( + keys: str, + pane_id: str | None = None, + session_name: str | None = None, + window_id: str | None = None, + enter: bool = True, + literal: bool = False, + suppress_history: bool = False, + socket_name: str | None = None, +) -> str: + """Send keys (commands or text) to a tmux pane. + + Parameters + ---------- + keys : str + The keys or text to send. + pane_id : str, optional + Pane ID (e.g. '%%1'). + session_name : str, optional + Session name for pane resolution. + window_id : str, optional + Window ID for pane resolution. + enter : bool + Whether to press Enter after sending keys. Default True. + literal : bool + Whether to send keys literally (no tmux interpretation). Default False. + suppress_history : bool + Whether to suppress shell history. Default False. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + Confirmation message. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + window_id=window_id, + ) + pane.send_keys( + keys, + enter=enter, + suppress_history=suppress_history, + literal=literal, + ) + return f"Keys sent to pane {pane.pane_id}" + + +@handle_tool_errors +def capture_pane( + pane_id: str | None = None, + session_name: str | None = None, + window_id: str | None = None, + start: int | None = None, + end: int | None = None, + socket_name: str | None = None, +) -> str: + """Capture the visible contents of a tmux pane. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%%1'). + session_name : str, optional + Session name for pane resolution. + window_id : str, optional + Window ID for pane resolution. + start : int, optional + Start line number (negative for scrollback history). + end : int, optional + End line number. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + Captured pane content as text. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + window_id=window_id, + ) + lines = pane.capture_pane(start=start, end=end) + return "\n".join(lines) + + +@handle_tool_errors +def resize_pane( + pane_id: str | None = None, + session_name: str | None = None, + window_id: str | None = None, + height: int | None = None, + width: int | None = None, + zoom: bool | None = None, + socket_name: str | None = None, +) -> str: + """Resize a tmux pane. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%%1'). + session_name : str, optional + Session name for pane resolution. + window_id : str, optional + Window ID for pane resolution. + height : int, optional + New height in lines. + width : int, optional + New width in columns. + zoom : bool, optional + Toggle pane zoom. If True, zoom the pane. If False, unzoom. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + JSON of the updated pane. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + window_id=window_id, + ) + if zoom is True: + pane.resize_pane(zoom=True) + elif zoom is False: + pane.resize_pane(zoom=False) + else: + pane.resize_pane(height=height, width=width) + return json.dumps(_serialize_pane(pane)) + + +@handle_tool_errors +def kill_pane( + pane_id: str | None = None, + session_name: str | None = None, + window_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Kill (close) a tmux pane. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%%1'). + session_name : str, optional + Session name for pane resolution. + window_id : str, optional + Window ID for pane resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + Confirmation message. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + window_id=window_id, + ) + pid = pane.pane_id + pane.cmd("kill-pane") + return f"Pane killed: {pid}" + + +@handle_tool_errors +def set_pane_title( + title: str, + pane_id: str | None = None, + session_name: str | None = None, + window_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Set the title of a tmux pane. + + Parameters + ---------- + title : str + The new pane title. + pane_id : str, optional + Pane ID (e.g. '%%1'). + session_name : str, optional + Session name for pane resolution. + window_id : str, optional + Window ID for pane resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + JSON of the updated pane. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + window_id=window_id, + ) + pane.set_title(title) + return json.dumps(_serialize_pane(pane)) + + +@handle_tool_errors +def get_pane_info( + pane_id: str | None = None, + session_name: str | None = None, + window_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Get detailed information about a tmux pane. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%%1'). + session_name : str, optional + Session name for pane resolution. + window_id : str, optional + Window ID for pane resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + JSON of pane details. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + window_id=window_id, + ) + return json.dumps(_serialize_pane(pane)) + + +@handle_tool_errors +def clear_pane( + pane_id: str | None = None, + session_name: str | None = None, + window_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Clear the contents of a tmux pane. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%%1'). + session_name : str, optional + Session name for pane resolution. + window_id : str, optional + Window ID for pane resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + Confirmation message. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + window_id=window_id, + ) + pane.clear() + return f"Pane cleared: {pane.pane_id}" + + +def register(mcp: FastMCP) -> None: + """Register pane-level tools with the MCP instance.""" + mcp.tool(annotations={"destructiveHint": True, "idempotentHint": False})(send_keys) + mcp.tool(annotations={"readOnlyHint": True})(capture_pane) + mcp.tool(annotations={"destructiveHint": False})(resize_pane) + mcp.tool(annotations={"destructiveHint": True})(kill_pane) + mcp.tool(annotations={"destructiveHint": False})(set_pane_title) + mcp.tool(annotations={"readOnlyHint": True})(get_pane_info) + mcp.tool(annotations={"destructiveHint": False})(clear_pane) diff --git a/src/libtmux/mcp/tools/server_tools.py b/src/libtmux/mcp/tools/server_tools.py new file mode 100644 index 000000000..e709d7a68 --- /dev/null +++ b/src/libtmux/mcp/tools/server_tools.py @@ -0,0 +1,144 @@ +"""MCP tools for tmux server operations.""" + +from __future__ import annotations + +import json +import typing as t + +from libtmux.mcp._utils import ( + _get_server, + _serialize_session, + handle_tool_errors, +) + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + + +@handle_tool_errors +def list_sessions(socket_name: str | None = None) -> str: + """List all tmux sessions. + + Parameters + ---------- + socket_name : str, optional + tmux socket name. Defaults to LIBTMUX_SOCKET env var. + + Returns + ------- + str + JSON array of session objects. + """ + server = _get_server(socket_name=socket_name) + sessions = server.sessions + return json.dumps([_serialize_session(s) for s in sessions]) + + +@handle_tool_errors +def create_session( + session_name: str | None = None, + window_name: str | None = None, + start_directory: str | None = None, + x: int | None = None, + y: int | None = None, + environment: dict[str, str] | None = None, + socket_name: str | None = None, +) -> str: + """Create a new tmux session. + + Parameters + ---------- + session_name : str, optional + Name for the new session. + window_name : str, optional + Name for the initial window. + start_directory : str, optional + Working directory for the session. + x : int, optional + Width of the initial window. + y : int, optional + Height of the initial window. + environment : dict, optional + Environment variables to set. + socket_name : str, optional + tmux socket name. Defaults to LIBTMUX_SOCKET env var. + + Returns + ------- + str + JSON object of the created session. + """ + server = _get_server(socket_name=socket_name) + kwargs: dict[str, t.Any] = {} + if session_name is not None: + kwargs["session_name"] = session_name + if window_name is not None: + kwargs["window_name"] = window_name + if start_directory is not None: + kwargs["start_directory"] = start_directory + if x is not None: + kwargs["x"] = x + if y is not None: + kwargs["y"] = y + if environment is not None: + kwargs["environment"] = environment + session = server.new_session(**kwargs) + return json.dumps(_serialize_session(session)) + + +@handle_tool_errors +def kill_server(socket_name: str | None = None) -> str: + """Kill the tmux server and all its sessions. + + Parameters + ---------- + socket_name : str, optional + tmux socket name. Defaults to LIBTMUX_SOCKET env var. + + Returns + ------- + str + Confirmation message. + """ + server = _get_server(socket_name=socket_name) + server.kill() + return "Server killed successfully" + + +@handle_tool_errors +def get_server_info(socket_name: str | None = None) -> str: + """Get information about the tmux server. + + Parameters + ---------- + socket_name : str, optional + tmux socket name. Defaults to LIBTMUX_SOCKET env var. + + Returns + ------- + str + JSON object with server info. + """ + server = _get_server(socket_name=socket_name) + info: dict[str, t.Any] = { + "is_alive": server.is_alive(), + "socket_name": server.socket_name, + "socket_path": str(server.socket_path) if server.socket_path else None, + "session_count": len(server.sessions) if server.is_alive() else 0, + } + try: + result = server.cmd("display-message", "-p", "#{version}") + info["version"] = result.stdout[0] if result.stdout else None + except Exception: + info["version"] = None + return json.dumps(info) + + +def register(mcp: FastMCP) -> None: + """Register server-level tools with the MCP instance.""" + mcp.tool(annotations={"readOnlyHint": True})(list_sessions) + mcp.tool(annotations={"destructiveHint": False, "idempotentHint": False})( + create_session + ) + mcp.tool(annotations={"destructiveHint": True})(kill_server) + mcp.tool(annotations={"readOnlyHint": True})(get_server_info) diff --git a/src/libtmux/mcp/tools/session_tools.py b/src/libtmux/mcp/tools/session_tools.py new file mode 100644 index 000000000..959b3d6e3 --- /dev/null +++ b/src/libtmux/mcp/tools/session_tools.py @@ -0,0 +1,166 @@ +"""MCP tools for tmux session operations.""" + +from __future__ import annotations + +import json +import typing as t + +from libtmux.constants import WindowDirection +from libtmux.mcp._utils import ( + _get_server, + _resolve_session, + _serialize_session, + _serialize_window, + handle_tool_errors, +) + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + + +@handle_tool_errors +def list_windows( + session_name: str | None = None, + session_id: str | None = None, + socket_name: str | None = None, +) -> str: + """List all windows in a tmux session. + + Parameters + ---------- + session_name : str, optional + Session name to look up. + session_id : str, optional + Session ID (e.g. '$1') to look up. + socket_name : str, optional + tmux socket name. Defaults to LIBTMUX_SOCKET env var. + + Returns + ------- + str + JSON array of window objects. + """ + server = _get_server(socket_name=socket_name) + session = _resolve_session(server, session_name=session_name, session_id=session_id) + windows = session.windows + return json.dumps([_serialize_window(w) for w in windows]) + + +@handle_tool_errors +def create_window( + session_name: str | None = None, + session_id: str | None = None, + window_name: str | None = None, + start_directory: str | None = None, + attach: bool = False, + direction: str | None = None, + socket_name: str | None = None, +) -> str: + """Create a new window in a tmux session. + + Parameters + ---------- + session_name : str, optional + Session name to look up. + session_id : str, optional + Session ID (e.g. '$1') to look up. + window_name : str, optional + Name for the new window. + start_directory : str, optional + Working directory for the new window. + attach : bool, optional + Whether to make the new window active. + direction : str, optional + Window placement direction: "before" or "after". + socket_name : str, optional + tmux socket name. Defaults to LIBTMUX_SOCKET env var. + + Returns + ------- + str + JSON object of the created window. + """ + server = _get_server(socket_name=socket_name) + session = _resolve_session(server, session_name=session_name, session_id=session_id) + kwargs: dict[str, t.Any] = {} + if window_name is not None: + kwargs["window_name"] = window_name + if start_directory is not None: + kwargs["start_directory"] = start_directory + kwargs["attach"] = attach + if direction is not None: + direction_map: dict[str, WindowDirection] = { + "before": WindowDirection.Before, + "after": WindowDirection.After, + } + kwargs["direction"] = direction_map[direction.lower()] + window = session.new_window(**kwargs) + return json.dumps(_serialize_window(window)) + + +@handle_tool_errors +def rename_session( + new_name: str, + session_name: str | None = None, + session_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Rename a tmux session. + + Parameters + ---------- + new_name : str + New name for the session. + session_name : str, optional + Current session name to look up. + session_id : str, optional + Session ID (e.g. '$1') to look up. + socket_name : str, optional + tmux socket name. Defaults to LIBTMUX_SOCKET env var. + + Returns + ------- + str + JSON object of the renamed session. + """ + server = _get_server(socket_name=socket_name) + session = _resolve_session(server, session_name=session_name, session_id=session_id) + session = session.rename_session(new_name) + return json.dumps(_serialize_session(session)) + + +@handle_tool_errors +def kill_session( + session_name: str | None = None, + session_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Kill a tmux session. + + Parameters + ---------- + session_name : str, optional + Session name to look up. + session_id : str, optional + Session ID (e.g. '$1') to look up. + socket_name : str, optional + tmux socket name. Defaults to LIBTMUX_SOCKET env var. + + Returns + ------- + str + Confirmation message. + """ + server = _get_server(socket_name=socket_name) + session = _resolve_session(server, session_name=session_name, session_id=session_id) + name = session.session_name or session.session_id + session.kill() + return f"Session killed: {name}" + + +def register(mcp: FastMCP) -> None: + """Register session-level tools with the MCP instance.""" + mcp.tool(annotations={"readOnlyHint": True})(list_windows) + mcp.tool(annotations={"destructiveHint": False})(create_window) + mcp.tool(annotations={"destructiveHint": False})(rename_session) + mcp.tool(annotations={"destructiveHint": True})(kill_session) diff --git a/src/libtmux/mcp/tools/window_tools.py b/src/libtmux/mcp/tools/window_tools.py new file mode 100644 index 000000000..9e0823e4f --- /dev/null +++ b/src/libtmux/mcp/tools/window_tools.py @@ -0,0 +1,320 @@ +"""MCP tools for tmux window operations.""" + +from __future__ import annotations + +import json +import typing as t + +from libtmux.constants import PaneDirection +from libtmux.mcp._utils import ( + _get_server, + _resolve_pane, + _resolve_window, + _serialize_pane, + _serialize_window, + handle_tool_errors, +) + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + +_DIRECTION_MAP: dict[str, PaneDirection] = { + "above": PaneDirection.Above, + "below": PaneDirection.Below, + "right": PaneDirection.Right, + "left": PaneDirection.Left, +} + + +@handle_tool_errors +def list_panes( + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + window_index: str | None = None, + socket_name: str | None = None, +) -> str: + """List all panes in a tmux window. + + Parameters + ---------- + session_name : str, optional + Session name to resolve the window from. + session_id : str, optional + Session ID to resolve the window from. + window_id : str, optional + Window ID (e.g. '@1'). + window_index : str, optional + Window index within the session. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + JSON array of serialized pane objects. + """ + server = _get_server(socket_name=socket_name) + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + session_id=session_id, + ) + return json.dumps([_serialize_pane(p) for p in window.panes]) + + +@handle_tool_errors +def split_window( + pane_id: str | None = None, + session_name: str | None = None, + window_id: str | None = None, + window_index: str | None = None, + direction: str | None = None, + size: str | int | None = None, + start_directory: str | None = None, + shell: str | None = None, + socket_name: str | None = None, +) -> str: + """Split a tmux window to create a new pane. + + Parameters + ---------- + pane_id : str, optional + Pane ID to split from. If given, the pane's window is used. + session_name : str, optional + Session name. + window_id : str, optional + Window ID (e.g. '@1'). + window_index : str, optional + Window index within the session. + direction : str, optional + Split direction: 'above', 'below', 'left', or 'right'. + size : str or int, optional + Size of the new pane (percentage or line count). + start_directory : str, optional + Working directory for the new pane. + shell : str, optional + Shell command to run in the new pane. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + JSON of the newly created pane. + """ + server = _get_server(socket_name=socket_name) + + if pane_id is not None: + pane = _resolve_pane(server, pane_id=pane_id) + window = pane.window + else: + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + ) + + pane_dir: PaneDirection | None = None + if direction is not None: + pane_dir = _DIRECTION_MAP.get(direction.lower()) + + new_pane = window.split( + direction=pane_dir, + size=size, + start_directory=start_directory, + shell=shell, + ) + return json.dumps(_serialize_pane(new_pane)) + + +@handle_tool_errors +def rename_window( + new_name: str, + window_id: str | None = None, + window_index: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Rename a tmux window. + + Parameters + ---------- + new_name : str + The new name for the window. + window_id : str, optional + Window ID (e.g. '@1'). + window_index : str, optional + Window index within the session. + session_name : str, optional + Session name. + session_id : str, optional + Session ID. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + JSON of the updated window. + """ + server = _get_server(socket_name=socket_name) + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + session_id=session_id, + ) + window.rename_window(new_name) + return json.dumps(_serialize_window(window)) + + +@handle_tool_errors +def kill_window( + window_id: str | None = None, + window_index: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Kill (close) a tmux window. + + Parameters + ---------- + window_id : str, optional + Window ID (e.g. '@1'). + window_index : str, optional + Window index within the session. + session_name : str, optional + Session name. + session_id : str, optional + Session ID. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + Confirmation message. + """ + server = _get_server(socket_name=socket_name) + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + session_id=session_id, + ) + wid = window.window_id + window.kill() + return f"Window killed: {wid}" + + +@handle_tool_errors +def select_layout( + layout: str, + window_id: str | None = None, + window_index: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Set the layout of a tmux window. + + Parameters + ---------- + layout : str + Layout name (e.g. 'even-horizontal', 'tiled') or custom layout string. + window_id : str, optional + Window ID (e.g. '@1'). + window_index : str, optional + Window index within the session. + session_name : str, optional + Session name. + session_id : str, optional + Session ID. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + JSON of the updated window. + """ + server = _get_server(socket_name=socket_name) + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + session_id=session_id, + ) + window.select_layout(layout) + return json.dumps(_serialize_window(window)) + + +@handle_tool_errors +def resize_window( + window_id: str | None = None, + window_index: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + height: int | None = None, + width: int | None = None, + socket_name: str | None = None, +) -> str: + """Resize a tmux window. + + Parameters + ---------- + window_id : str, optional + Window ID (e.g. '@1'). + window_index : str, optional + Window index within the session. + session_name : str, optional + Session name. + session_id : str, optional + Session ID. + height : int, optional + New height in lines. + width : int, optional + New width in columns. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + JSON of the updated window. + """ + server = _get_server(socket_name=socket_name) + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + session_id=session_id, + ) + cmd_args: list[str] = [] + if width is not None: + cmd_args.extend(["-x", str(width)]) + if height is not None: + cmd_args.extend(["-y", str(height)]) + if cmd_args: + window.cmd("resize-window", *cmd_args) + return json.dumps(_serialize_window(window)) + + +def register(mcp: FastMCP) -> None: + """Register window-level tools with the MCP instance.""" + mcp.tool(annotations={"readOnlyHint": True})(list_panes) + mcp.tool(annotations={"destructiveHint": False})(split_window) + mcp.tool(annotations={"destructiveHint": False})(rename_window) + mcp.tool(annotations={"destructiveHint": True})(kill_window) + mcp.tool(annotations={"destructiveHint": False})(select_layout) + mcp.tool(annotations={"destructiveHint": False})(resize_window) From 8c35b3255206ee4ad7f31eef82d5c0707db30e0d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 10:30:21 -0500 Subject: [PATCH 03/47] mcp(feat[resources]): Add tmux:// URI resources for hierarchy browsing why: MCP resources let agents browse tmux state via URI patterns. what: - tmux://sessions - list all sessions - tmux://sessions/{session_name} - session detail with windows - tmux://sessions/{session_name}/windows - windows in session - tmux://sessions/{session_name}/windows/{window_index} - window with panes - tmux://panes/{pane_id} - pane details - tmux://panes/{pane_id}/content - captured pane text --- src/libtmux/mcp/resources/__init__.py | 15 +++ src/libtmux/mcp/resources/hierarchy.py | 171 +++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 src/libtmux/mcp/resources/__init__.py create mode 100644 src/libtmux/mcp/resources/hierarchy.py diff --git a/src/libtmux/mcp/resources/__init__.py b/src/libtmux/mcp/resources/__init__.py new file mode 100644 index 000000000..47a48eeae --- /dev/null +++ b/src/libtmux/mcp/resources/__init__.py @@ -0,0 +1,15 @@ +"""MCP resource registration for libtmux.""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + + +def register_resources(mcp: FastMCP) -> None: + """Register all resource modules with the FastMCP instance.""" + from libtmux.mcp.resources import hierarchy + + hierarchy.register(mcp) diff --git a/src/libtmux/mcp/resources/hierarchy.py b/src/libtmux/mcp/resources/hierarchy.py new file mode 100644 index 000000000..36062a4f3 --- /dev/null +++ b/src/libtmux/mcp/resources/hierarchy.py @@ -0,0 +1,171 @@ +"""MCP resources for tmux object hierarchy.""" + +from __future__ import annotations + +import json +import logging +import typing as t + +from libtmux.mcp._utils import ( + _get_server, + _serialize_pane, + _serialize_session, + _serialize_window, +) + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + +logger = logging.getLogger(__name__) + + +def register(mcp: FastMCP) -> None: + """Register hierarchy resources with the FastMCP instance.""" + + @mcp.resource("tmux://sessions") + def get_sessions() -> str: + """List all tmux sessions. + + Returns + ------- + str + JSON array of session objects. + """ + try: + server = _get_server() + sessions = [_serialize_session(s) for s in server.sessions] + return json.dumps(sessions, indent=2) + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.resource("tmux://sessions/{session_name}") + def get_session(session_name: str) -> str: + """Get details of a specific tmux session. + + Parameters + ---------- + session_name : str + The session name. + + Returns + ------- + str + JSON object with session info and its windows. + """ + try: + server = _get_server() + session = server.sessions.get(session_name=session_name, default=None) + if session is None: + return json.dumps({"error": f"Session not found: {session_name}"}) + + result = _serialize_session(session) + result["windows"] = [_serialize_window(w) for w in session.windows] + return json.dumps(result, indent=2) + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.resource("tmux://sessions/{session_name}/windows") + def get_session_windows(session_name: str) -> str: + """List all windows in a tmux session. + + Parameters + ---------- + session_name : str + The session name. + + Returns + ------- + str + JSON array of window objects. + """ + try: + server = _get_server() + session = server.sessions.get(session_name=session_name, default=None) + if session is None: + return json.dumps({"error": f"Session not found: {session_name}"}) + + windows = [_serialize_window(w) for w in session.windows] + return json.dumps(windows, indent=2) + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.resource("tmux://sessions/{session_name}/windows/{window_index}") + def get_window(session_name: str, window_index: str) -> str: + """Get details of a specific window in a session. + + Parameters + ---------- + session_name : str + The session name. + window_index : str + The window index within the session. + + Returns + ------- + str + JSON object with window info and its panes. + """ + try: + server = _get_server() + session = server.sessions.get(session_name=session_name, default=None) + if session is None: + return json.dumps({"error": f"Session not found: {session_name}"}) + + window = session.windows.get(window_index=window_index, default=None) + if window is None: + return json.dumps({"error": f"Window not found: index {window_index}"}) + + result = _serialize_window(window) + result["panes"] = [_serialize_pane(p) for p in window.panes] + return json.dumps(result, indent=2) + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.resource("tmux://panes/{pane_id}") + def get_pane(pane_id: str) -> str: + """Get details of a specific pane. + + Parameters + ---------- + pane_id : str + The pane ID (e.g. '%%1'). + + Returns + ------- + str + JSON object of pane details. + """ + try: + server = _get_server() + pane = server.panes.get(pane_id=pane_id, default=None) + if pane is None: + return json.dumps({"error": f"Pane not found: {pane_id}"}) + + return json.dumps(_serialize_pane(pane), indent=2) + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.resource("tmux://panes/{pane_id}/content") + def get_pane_content(pane_id: str) -> str: + """Capture and return the content of a pane. + + Parameters + ---------- + pane_id : str + The pane ID (e.g. '%%1'). + + Returns + ------- + str + Plain text captured pane content. + """ + try: + server = _get_server() + pane = server.panes.get(pane_id=pane_id, default=None) + if pane is None: + return f"Error: Pane not found: {pane_id}" + + lines = pane.capture_pane() + return "\n".join(lines) + except Exception as e: + return f"Error: {e}" From a883e7ea359244bdca7433179e248f989a34c816 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 10:30:29 -0500 Subject: [PATCH 04/47] mcp(test): Add 49 tests for MCP tools, resources, and utilities why: Ensure MCP server functionality works correctly with live tmux. what: - Add conftest.py with mcp_server, mcp_session, mcp_window, mcp_pane fixtures and server cache cleanup - Add test_utils.py for resolver and serializer functions - Add test files for all 6 tool modules - Add test_resources.py with mock MCP for resource functions --- tests/mcp/__init__.py | 1 + tests/mcp/conftest.py | 57 +++++++++++++++ tests/mcp/test_env_tools.py | 53 ++++++++++++++ tests/mcp/test_option_tools.py | 39 ++++++++++ tests/mcp/test_pane_tools.py | 83 ++++++++++++++++++++++ tests/mcp/test_resources.py | 101 ++++++++++++++++++++++++++ tests/mcp/test_server_tools.py | 75 ++++++++++++++++++++ tests/mcp/test_session_tools.py | 73 +++++++++++++++++++ tests/mcp/test_utils.py | 122 ++++++++++++++++++++++++++++++++ tests/mcp/test_window_tools.py | 92 ++++++++++++++++++++++++ 10 files changed, 696 insertions(+) create mode 100644 tests/mcp/__init__.py create mode 100644 tests/mcp/conftest.py create mode 100644 tests/mcp/test_env_tools.py create mode 100644 tests/mcp/test_option_tools.py create mode 100644 tests/mcp/test_pane_tools.py create mode 100644 tests/mcp/test_resources.py create mode 100644 tests/mcp/test_server_tools.py create mode 100644 tests/mcp/test_session_tools.py create mode 100644 tests/mcp/test_utils.py create mode 100644 tests/mcp/test_window_tools.py diff --git a/tests/mcp/__init__.py b/tests/mcp/__init__.py new file mode 100644 index 000000000..39ed7fe5a --- /dev/null +++ b/tests/mcp/__init__.py @@ -0,0 +1 @@ +"""Tests for libtmux MCP server.""" diff --git a/tests/mcp/conftest.py b/tests/mcp/conftest.py new file mode 100644 index 000000000..e6c9a5d5f --- /dev/null +++ b/tests/mcp/conftest.py @@ -0,0 +1,57 @@ +"""Test fixtures for libtmux MCP server tests.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.mcp._utils import _server_cache + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + from libtmux.window import Window + + +@pytest.fixture(autouse=True) +def _clear_server_cache() -> t.Generator[None, None, None]: + """Clear the MCP server cache between tests.""" + _server_cache.clear() + yield + _server_cache.clear() + + +@pytest.fixture +def mcp_server(server: Server) -> Server: + """Provide a libtmux Server pre-registered in the MCP cache. + + This fixture sets up the server cache so MCP tools can find the + test server without environment variables. + """ + cache_key = (server.socket_name, None) + _server_cache[cache_key] = server + # Also register as default (None, None) for tools that don't specify a socket + _server_cache[(None, None)] = server + return server + + +@pytest.fixture +def mcp_session(mcp_server: Server, session: Session) -> Session: + """Provide a session accessible via MCP tools.""" + return session + + +@pytest.fixture +def mcp_window(mcp_session: Session) -> Window: + """Provide a window accessible via MCP tools.""" + return mcp_session.active_window + + +@pytest.fixture +def mcp_pane(mcp_window: Window) -> Pane: + """Provide a pane accessible via MCP tools.""" + active_pane = mcp_window.active_pane + assert active_pane is not None + return active_pane diff --git a/tests/mcp/test_env_tools.py b/tests/mcp/test_env_tools.py new file mode 100644 index 000000000..ee00cbd15 --- /dev/null +++ b/tests/mcp/test_env_tools.py @@ -0,0 +1,53 @@ +"""Tests for libtmux MCP environment tools.""" + +from __future__ import annotations + +import json +import typing as t + +from libtmux.mcp.tools.env_tools import set_environment, show_environment + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session + + +def test_show_environment(mcp_server: Server, mcp_session: Session) -> None: + """show_environment returns environment variables.""" + result = show_environment(socket_name=mcp_server.socket_name) + data = json.loads(result) + assert isinstance(data, dict) + + +def test_set_environment(mcp_server: Server, mcp_session: Session) -> None: + """set_environment sets an environment variable.""" + result = set_environment( + name="MCP_TEST_VAR", + value="test_value", + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert data["status"] == "set" + assert data["name"] == "MCP_TEST_VAR" + + +def test_set_and_show_environment(mcp_server: Server, mcp_session: Session) -> None: + """set_environment value is readable via show_environment.""" + set_environment( + name="MCP_ROUND_TRIP", + value="hello", + socket_name=mcp_server.socket_name, + ) + result = show_environment(socket_name=mcp_server.socket_name) + data = json.loads(result) + assert data.get("MCP_ROUND_TRIP") == "hello" + + +def test_show_environment_session(mcp_server: Server, mcp_session: Session) -> None: + """show_environment can target a specific session.""" + result = show_environment( + session_name=mcp_session.session_name, + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert isinstance(data, dict) diff --git a/tests/mcp/test_option_tools.py b/tests/mcp/test_option_tools.py new file mode 100644 index 000000000..b51793f0c --- /dev/null +++ b/tests/mcp/test_option_tools.py @@ -0,0 +1,39 @@ +"""Tests for libtmux MCP option tools.""" + +from __future__ import annotations + +import json +import typing as t + +from libtmux.mcp.tools.option_tools import set_option, show_option + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session + + +def test_show_option(mcp_server: Server, mcp_session: Session) -> None: + """show_option returns an option value.""" + result = show_option( + option="base-index", + scope="session", + global_=True, + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert data["option"] == "base-index" + assert "value" in data + + +def test_set_option(mcp_server: Server, mcp_session: Session) -> None: + """set_option sets a tmux option.""" + result = set_option( + option="display-time", + value="3000", + scope="server", + global_=True, + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert data["status"] == "set" + assert data["option"] == "display-time" diff --git a/tests/mcp/test_pane_tools.py b/tests/mcp/test_pane_tools.py new file mode 100644 index 000000000..29f63ff4c --- /dev/null +++ b/tests/mcp/test_pane_tools.py @@ -0,0 +1,83 @@ +"""Tests for libtmux MCP pane tools.""" + +from __future__ import annotations + +import json +import typing as t + +from libtmux.mcp.tools.pane_tools import ( + capture_pane, + clear_pane, + get_pane_info, + kill_pane, + send_keys, + set_pane_title, +) + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + + +def test_send_keys(mcp_server: Server, mcp_pane: Pane) -> None: + """send_keys sends keys to a pane.""" + result = send_keys( + keys="echo hello_mcp", + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert "sent" in result.lower() + + +def test_capture_pane(mcp_server: Server, mcp_pane: Pane) -> None: + """capture_pane returns pane content.""" + result = capture_pane( + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert isinstance(result, str) + + +def test_get_pane_info(mcp_server: Server, mcp_pane: Pane) -> None: + """get_pane_info returns detailed pane info.""" + result = get_pane_info( + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert data["pane_id"] == mcp_pane.pane_id + assert "pane_width" in data + assert "pane_height" in data + + +def test_set_pane_title(mcp_server: Server, mcp_pane: Pane) -> None: + """set_pane_title sets the pane title.""" + result = set_pane_title( + title="my_test_title", + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert data["pane_id"] == mcp_pane.pane_id + + +def test_clear_pane(mcp_server: Server, mcp_pane: Pane) -> None: + """clear_pane clears pane content.""" + result = clear_pane( + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert "cleared" in result.lower() + + +def test_kill_pane(mcp_server: Server, mcp_session: Session) -> None: + """kill_pane kills a pane.""" + window = mcp_session.active_window + new_pane = window.split() + pane_id = new_pane.pane_id + result = kill_pane( + pane_id=pane_id, + socket_name=mcp_server.socket_name, + ) + assert "killed" in result.lower() diff --git a/tests/mcp/test_resources.py b/tests/mcp/test_resources.py new file mode 100644 index 000000000..7485f9273 --- /dev/null +++ b/tests/mcp/test_resources.py @@ -0,0 +1,101 @@ +"""Tests for libtmux MCP resources.""" + +from __future__ import annotations + +import json +import typing as t + +import pytest + +from libtmux.mcp.resources.hierarchy import register + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + from libtmux.window import Window + + +@pytest.fixture +def resource_functions(mcp_server: Server) -> dict[str, t.Any]: + """Register resources and return the function references. + + Since resources are registered via decorators, we capture them + by creating a mock FastMCP and collecting registered functions. + """ + functions: dict[str, t.Any] = {} + + class MockMCP: + def resource(self, uri: str) -> t.Any: + def decorator(fn: t.Any) -> t.Any: + functions[uri] = fn + return fn + + return decorator + + register(MockMCP()) # type: ignore[arg-type] + return functions + + +def test_sessions_resource( + resource_functions: dict[str, t.Any], mcp_session: Session +) -> None: + """tmux://sessions returns session list.""" + fn = resource_functions["tmux://sessions"] + result = fn() + data = json.loads(result) + assert isinstance(data, list) + assert len(data) >= 1 + + +def test_session_detail_resource( + resource_functions: dict[str, t.Any], mcp_session: Session +) -> None: + """tmux://sessions/{name} returns session with windows.""" + fn = resource_functions["tmux://sessions/{session_name}"] + result = fn(mcp_session.session_name) + data = json.loads(result) + assert "session_id" in data + assert "windows" in data + + +def test_session_windows_resource( + resource_functions: dict[str, t.Any], mcp_session: Session +) -> None: + """tmux://sessions/{name}/windows returns window list.""" + fn = resource_functions["tmux://sessions/{session_name}/windows"] + result = fn(mcp_session.session_name) + data = json.loads(result) + assert isinstance(data, list) + + +def test_window_detail_resource( + resource_functions: dict[str, t.Any], + mcp_session: Session, + mcp_window: Window, +) -> None: + """tmux://sessions/{name}/windows/{index} returns window with panes.""" + fn = resource_functions["tmux://sessions/{session_name}/windows/{window_index}"] + result = fn(mcp_session.session_name, mcp_window.window_index) + data = json.loads(result) + assert "window_id" in data + assert "panes" in data + + +def test_pane_detail_resource( + resource_functions: dict[str, t.Any], mcp_pane: Pane +) -> None: + """tmux://panes/{pane_id} returns pane details.""" + fn = resource_functions["tmux://panes/{pane_id}"] + result = fn(mcp_pane.pane_id) + data = json.loads(result) + assert data["pane_id"] == mcp_pane.pane_id + + +def test_pane_content_resource( + resource_functions: dict[str, t.Any], mcp_pane: Pane +) -> None: + """tmux://panes/{pane_id}/content returns captured text.""" + fn = resource_functions["tmux://panes/{pane_id}/content"] + result = fn(mcp_pane.pane_id) + assert isinstance(result, str) diff --git a/tests/mcp/test_server_tools.py b/tests/mcp/test_server_tools.py new file mode 100644 index 000000000..944bdd7d4 --- /dev/null +++ b/tests/mcp/test_server_tools.py @@ -0,0 +1,75 @@ +"""Tests for libtmux MCP server tools.""" + +from __future__ import annotations + +import json +import typing as t + +import pytest + +from libtmux.mcp.tools.server_tools import ( + create_session, + get_server_info, + kill_server, + list_sessions, +) + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session + + +def test_list_sessions(mcp_server: Server, mcp_session: Session) -> None: + """list_sessions returns JSON array of sessions.""" + result = list_sessions(socket_name=mcp_server.socket_name) + data = json.loads(result) + assert isinstance(data, list) + assert len(data) >= 1 + session_ids = [s["session_id"] for s in data] + assert mcp_session.session_id in session_ids + + +def test_list_sessions_empty_server(mcp_server: Server) -> None: + """list_sessions returns empty array when no sessions.""" + # Kill all sessions first + for s in mcp_server.sessions: + s.kill() + result = list_sessions(socket_name=mcp_server.socket_name) + data = json.loads(result) + assert data == [] + + +def test_create_session(mcp_server: Server) -> None: + """create_session creates a new tmux session.""" + result = create_session( + session_name="mcp_test_new", + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert data["session_name"] == "mcp_test_new" + assert data["session_id"] is not None + + +def test_create_session_duplicate(mcp_server: Server, mcp_session: Session) -> None: + """create_session raises error for duplicate session name.""" + from fastmcp.exceptions import ToolError + + with pytest.raises(ToolError): + create_session( + session_name=mcp_session.session_name, + socket_name=mcp_server.socket_name, + ) + + +def test_get_server_info(mcp_server: Server, mcp_session: Session) -> None: + """get_server_info returns server status.""" + result = get_server_info(socket_name=mcp_server.socket_name) + data = json.loads(result) + assert data["is_alive"] is True + assert data["session_count"] >= 1 + + +def test_kill_server(mcp_server: Server, mcp_session: Session) -> None: + """kill_server kills the tmux server.""" + result = kill_server(socket_name=mcp_server.socket_name) + assert "killed" in result.lower() diff --git a/tests/mcp/test_session_tools.py b/tests/mcp/test_session_tools.py new file mode 100644 index 000000000..e831fb060 --- /dev/null +++ b/tests/mcp/test_session_tools.py @@ -0,0 +1,73 @@ +"""Tests for libtmux MCP session tools.""" + +from __future__ import annotations + +import json +import typing as t + +from libtmux.mcp.tools.session_tools import ( + create_window, + kill_session, + list_windows, + rename_session, +) + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session + + +def test_list_windows(mcp_server: Server, mcp_session: Session) -> None: + """list_windows returns JSON array of windows.""" + result = list_windows( + session_name=mcp_session.session_name, + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert isinstance(data, list) + assert len(data) >= 1 + assert "window_id" in data[0] + + +def test_list_windows_by_id(mcp_server: Server, mcp_session: Session) -> None: + """list_windows can find session by ID.""" + result = list_windows( + session_id=mcp_session.session_id, + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert len(data) >= 1 + + +def test_create_window(mcp_server: Server, mcp_session: Session) -> None: + """create_window creates a new window in a session.""" + result = create_window( + session_name=mcp_session.session_name, + window_name="mcp_test_win", + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert data["window_name"] == "mcp_test_win" + + +def test_rename_session(mcp_server: Server, mcp_session: Session) -> None: + """rename_session renames an existing session.""" + original_name = mcp_session.session_name + result = rename_session( + new_name="mcp_renamed", + session_name=original_name, + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert data["session_name"] == "mcp_renamed" + + +def test_kill_session(mcp_server: Server) -> None: + """kill_session kills a session.""" + mcp_server.new_session(session_name="mcp_kill_me") + result = kill_session( + session_name="mcp_kill_me", + socket_name=mcp_server.socket_name, + ) + assert "killed" in result.lower() + assert not mcp_server.has_session("mcp_kill_me") diff --git a/tests/mcp/test_utils.py b/tests/mcp/test_utils.py new file mode 100644 index 000000000..44bd86a30 --- /dev/null +++ b/tests/mcp/test_utils.py @@ -0,0 +1,122 @@ +"""Tests for libtmux MCP utilities.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux import exc +from libtmux.mcp._utils import ( + _get_server, + _resolve_pane, + _resolve_session, + _resolve_window, + _serialize_pane, + _serialize_session, + _serialize_window, + _server_cache, +) + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + from libtmux.window import Window + + +def test_get_server_creates_server() -> None: + """_get_server creates a Server instance.""" + server = _get_server(socket_name="test_mcp_util") + assert server is not None + assert server.socket_name == "test_mcp_util" + + +def test_get_server_caches(monkeypatch: pytest.MonkeyPatch) -> None: + """_get_server returns the same instance for the same socket.""" + _server_cache.clear() + s1 = _get_server(socket_name="test_cache") + s2 = _get_server(socket_name="test_cache") + assert s1 is s2 + + +def test_get_server_env_var(monkeypatch: pytest.MonkeyPatch) -> None: + """_get_server reads LIBTMUX_SOCKET env var.""" + _server_cache.clear() + monkeypatch.setenv("LIBTMUX_SOCKET", "env_socket") + server = _get_server() + assert server.socket_name == "env_socket" + + +def test_resolve_session_by_name(mcp_server: Server, mcp_session: Session) -> None: + """_resolve_session finds session by name.""" + result = _resolve_session(mcp_server, session_name=mcp_session.session_name) + assert result.session_id == mcp_session.session_id + + +def test_resolve_session_by_id(mcp_server: Server, mcp_session: Session) -> None: + """_resolve_session finds session by ID.""" + result = _resolve_session(mcp_server, session_id=mcp_session.session_id) + assert result.session_id == mcp_session.session_id + + +def test_resolve_session_not_found(mcp_server: Server, mcp_session: Session) -> None: + """_resolve_session raises when session not found.""" + with pytest.raises(exc.TmuxObjectDoesNotExist): + _resolve_session(mcp_server, session_name="nonexistent_session_xyz") + + +def test_resolve_session_fallback(mcp_server: Server, mcp_session: Session) -> None: + """_resolve_session returns first session when no filter given.""" + result = _resolve_session(mcp_server) + assert result.session_id is not None + + +def test_resolve_window_by_id(mcp_server: Server, mcp_window: Window) -> None: + """_resolve_window finds window by ID.""" + result = _resolve_window(mcp_server, window_id=mcp_window.window_id) + assert result.window_id == mcp_window.window_id + + +def test_resolve_window_not_found(mcp_server: Server, mcp_session: Session) -> None: + """_resolve_window raises when window not found.""" + with pytest.raises(exc.TmuxObjectDoesNotExist): + _resolve_window(mcp_server, window_id="@99999") + + +def test_resolve_pane_by_id(mcp_server: Server, mcp_pane: Pane) -> None: + """_resolve_pane finds pane by ID.""" + result = _resolve_pane(mcp_server, pane_id=mcp_pane.pane_id) + assert result.pane_id == mcp_pane.pane_id + + +def test_resolve_pane_not_found(mcp_server: Server, mcp_session: Session) -> None: + """_resolve_pane raises when pane not found.""" + with pytest.raises(exc.PaneNotFound): + _resolve_pane(mcp_server, pane_id="%99999") + + +def test_serialize_session(mcp_session: Session) -> None: + """_serialize_session produces expected keys.""" + data = _serialize_session(mcp_session) + assert "session_id" in data + assert "session_name" in data + assert "window_count" in data + assert data["session_id"] == mcp_session.session_id + + +def test_serialize_window(mcp_window: Window) -> None: + """_serialize_window produces expected keys.""" + data = _serialize_window(mcp_window) + assert "window_id" in data + assert "window_name" in data + assert "window_index" in data + assert "pane_count" in data + + +def test_serialize_pane(mcp_pane: Pane) -> None: + """_serialize_pane produces expected keys.""" + data = _serialize_pane(mcp_pane) + assert "pane_id" in data + assert "window_id" in data + assert "session_id" in data diff --git a/tests/mcp/test_window_tools.py b/tests/mcp/test_window_tools.py new file mode 100644 index 000000000..3facfa5b0 --- /dev/null +++ b/tests/mcp/test_window_tools.py @@ -0,0 +1,92 @@ +"""Tests for libtmux MCP window tools.""" + +from __future__ import annotations + +import json +import typing as t + +from libtmux.mcp.tools.window_tools import ( + kill_window, + list_panes, + rename_window, + select_layout, + split_window, +) + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session + + +def test_list_panes(mcp_server: Server, mcp_session: Session) -> None: + """list_panes returns JSON array of panes.""" + window = mcp_session.active_window + result = list_panes( + window_id=window.window_id, + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert isinstance(data, list) + assert len(data) >= 1 + assert "pane_id" in data[0] + + +def test_split_window(mcp_server: Server, mcp_session: Session) -> None: + """split_window creates a new pane.""" + window = mcp_session.active_window + initial_pane_count = len(window.panes) + result = split_window( + window_id=window.window_id, + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert "pane_id" in data + assert len(window.panes) == initial_pane_count + 1 + + +def test_split_window_with_direction(mcp_server: Server, mcp_session: Session) -> None: + """split_window respects direction parameter.""" + window = mcp_session.active_window + result = split_window( + window_id=window.window_id, + direction="right", + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert "pane_id" in data + + +def test_rename_window(mcp_server: Server, mcp_session: Session) -> None: + """rename_window renames a window.""" + window = mcp_session.active_window + result = rename_window( + new_name="mcp_renamed_win", + window_id=window.window_id, + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert data["window_name"] == "mcp_renamed_win" + + +def test_select_layout(mcp_server: Server, mcp_session: Session) -> None: + """select_layout changes window layout.""" + window = mcp_session.active_window + window.split() + result = select_layout( + layout="even-horizontal", + window_id=window.window_id, + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert "window_id" in data + + +def test_kill_window(mcp_server: Server, mcp_session: Session) -> None: + """kill_window kills a window.""" + new_window = mcp_session.new_window(window_name="mcp_kill_win") + window_id = new_window.window_id + result = kill_window( + window_id=window_id, + socket_name=mcp_server.socket_name, + ) + assert "killed" in result.lower() From fae8ebf8350057a389d128f1d16ee3f36bc917c2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 11:21:32 -0500 Subject: [PATCH 05/47] mcp(fix[_utils]): Add tmux_bin to cache key and is_alive eviction why: Cache key only included (socket_name, socket_path), so changing LIBTMUX_TMUX_BIN between calls returned a stale Server. Dead servers were never evicted from the cache. what: - Change cache key to 3-tuple (socket_name, socket_path, tmux_bin) - Add is_alive() check on cache hit to evict dead servers - Add _invalidate_server() for explicit cache eviction - Call _invalidate_server() in kill_server tool after server.kill() - Update test fixtures for 3-tuple cache keys - Add tests for is_alive eviction and _invalidate_server --- src/libtmux/mcp/_utils.py | 29 +++++++++++++++++++++++++-- src/libtmux/mcp/tools/server_tools.py | 2 ++ tests/mcp/conftest.py | 6 +++--- tests/mcp/test_utils.py | 24 ++++++++++++++++++++++ 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/libtmux/mcp/_utils.py b/src/libtmux/mcp/_utils.py index f6a5135bc..a76ccf599 100644 --- a/src/libtmux/mcp/_utils.py +++ b/src/libtmux/mcp/_utils.py @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) -_server_cache: dict[tuple[str | None, str | None], Server] = {} +_server_cache: dict[tuple[str | None, str | None, str | None], Server] = {} def _get_server( @@ -49,7 +49,12 @@ def _get_server( tmux_bin = os.environ.get("LIBTMUX_TMUX_BIN") - cache_key = (socket_name, socket_path) + cache_key = (socket_name, socket_path, tmux_bin) + if cache_key in _server_cache: + cached = _server_cache[cache_key] + if not cached.is_alive(): + del _server_cache[cache_key] + if cache_key not in _server_cache: kwargs: dict[str, t.Any] = {} if socket_name is not None: @@ -63,6 +68,26 @@ def _get_server( return _server_cache[cache_key] +def _invalidate_server( + socket_name: str | None = None, + socket_path: str | None = None, +) -> None: + """Evict a server from the cache. + + Parameters + ---------- + socket_name : str, optional + tmux socket name used in the cache key. + socket_path : str, optional + tmux socket path used in the cache key. + """ + keys_to_remove = [ + key for key in _server_cache if key[0] == socket_name and key[1] == socket_path + ] + for key in keys_to_remove: + del _server_cache[key] + + def _resolve_session( server: Server, session_name: str | None = None, diff --git a/src/libtmux/mcp/tools/server_tools.py b/src/libtmux/mcp/tools/server_tools.py index e709d7a68..a6ca04ebc 100644 --- a/src/libtmux/mcp/tools/server_tools.py +++ b/src/libtmux/mcp/tools/server_tools.py @@ -7,6 +7,7 @@ from libtmux.mcp._utils import ( _get_server, + _invalidate_server, _serialize_session, handle_tool_errors, ) @@ -102,6 +103,7 @@ def kill_server(socket_name: str | None = None) -> str: """ server = _get_server(socket_name=socket_name) server.kill() + _invalidate_server(socket_name=socket_name) return "Server killed successfully" diff --git a/tests/mcp/conftest.py b/tests/mcp/conftest.py index e6c9a5d5f..a1a108181 100644 --- a/tests/mcp/conftest.py +++ b/tests/mcp/conftest.py @@ -30,10 +30,10 @@ def mcp_server(server: Server) -> Server: This fixture sets up the server cache so MCP tools can find the test server without environment variables. """ - cache_key = (server.socket_name, None) + cache_key = (server.socket_name, None, None) _server_cache[cache_key] = server - # Also register as default (None, None) for tools that don't specify a socket - _server_cache[(None, None)] = server + # Also register as default for tools that don't specify a socket + _server_cache[(None, None, None)] = server return server diff --git a/tests/mcp/test_utils.py b/tests/mcp/test_utils.py index 44bd86a30..642ba8827 100644 --- a/tests/mcp/test_utils.py +++ b/tests/mcp/test_utils.py @@ -9,6 +9,7 @@ from libtmux import exc from libtmux.mcp._utils import ( _get_server, + _invalidate_server, _resolve_pane, _resolve_session, _resolve_window, @@ -36,8 +37,12 @@ def test_get_server_caches(monkeypatch: pytest.MonkeyPatch) -> None: """_get_server returns the same instance for the same socket.""" _server_cache.clear() s1 = _get_server(socket_name="test_cache") + # Simulate a live server so the cache is not evicted + monkeypatch.setattr(s1, "is_alive", lambda: True) s2 = _get_server(socket_name="test_cache") assert s1 is s2 + # Verify 3-tuple cache key includes tmux_bin + assert (s1.socket_name, None, None) in _server_cache def test_get_server_env_var(monkeypatch: pytest.MonkeyPatch) -> None: @@ -120,3 +125,22 @@ def test_serialize_pane(mcp_pane: Pane) -> None: assert "pane_id" in data assert "window_id" in data assert "session_id" in data + + +def test_get_server_evicts_dead(monkeypatch: pytest.MonkeyPatch) -> None: + """_get_server evicts cached server when is_alive returns False.""" + _server_cache.clear() + s1 = _get_server(socket_name="test_evict") + # Patch is_alive to return False to simulate a dead server + monkeypatch.setattr(s1, "is_alive", lambda: False) + s2 = _get_server(socket_name="test_evict") + assert s1 is not s2 + + +def test_invalidate_server() -> None: + """_invalidate_server removes matching entries from cache.""" + _server_cache.clear() + _get_server(socket_name="test_inv") + assert len(_server_cache) == 1 + _invalidate_server(socket_name="test_inv") + assert len(_server_cache) == 0 From c7d75ffe03b2f6f457f0883dd30510e162a9c93e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 11:22:23 -0500 Subject: [PATCH 06/47] mcp(fix[pane_tools]): Replace deprecated resize_pane with state-aware zoom why: pane.resize_pane() always raises DeprecatedError since libtmux v0.28, making the resize_pane MCP tool 100% broken. what: - Replace pane.resize_pane() with pane.resize() for height/width - Add state-aware zoom: check window_zoomed_flag before toggling - Add mutual exclusivity check for zoom + height/width - Add tests for dimensions, zoom, and mutual exclusivity --- src/libtmux/mcp/tools/pane_tools.py | 19 +++++++++---- tests/mcp/test_pane_tools.py | 44 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index 3b9e4a17b..7105e487b 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -145,6 +145,12 @@ def resize_pane( str JSON of the updated pane. """ + from fastmcp.exceptions import ToolError + + if zoom is not None and (height is not None or width is not None): + msg = "Cannot combine zoom with height/width" + raise ToolError(msg) + server = _get_server(socket_name=socket_name) pane = _resolve_pane( server, @@ -152,12 +158,15 @@ def resize_pane( session_name=session_name, window_id=window_id, ) - if zoom is True: - pane.resize_pane(zoom=True) - elif zoom is False: - pane.resize_pane(zoom=False) + if zoom is not None: + window = pane.window + is_zoomed = getattr(window, "window_zoomed_flag", "0") == "1" + if zoom and not is_zoomed: + pane.resize(zoom=True) + elif not zoom and is_zoomed: + pane.resize(zoom=True) # toggle off else: - pane.resize_pane(height=height, width=width) + pane.resize(height=height, width=width) return json.dumps(_serialize_pane(pane)) diff --git a/tests/mcp/test_pane_tools.py b/tests/mcp/test_pane_tools.py index 29f63ff4c..175bc01a2 100644 --- a/tests/mcp/test_pane_tools.py +++ b/tests/mcp/test_pane_tools.py @@ -5,11 +5,15 @@ import json import typing as t +import pytest +from fastmcp.exceptions import ToolError + from libtmux.mcp.tools.pane_tools import ( capture_pane, clear_pane, get_pane_info, kill_pane, + resize_pane, send_keys, set_pane_title, ) @@ -71,6 +75,46 @@ def test_clear_pane(mcp_server: Server, mcp_pane: Pane) -> None: assert "cleared" in result.lower() +def test_resize_pane_dimensions(mcp_server: Server, mcp_pane: Pane) -> None: + """resize_pane resizes a pane with height/width.""" + result = resize_pane( + pane_id=mcp_pane.pane_id, + height=10, + width=40, + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert data["pane_id"] == mcp_pane.pane_id + + +def test_resize_pane_zoom(mcp_server: Server, mcp_session: Session) -> None: + """resize_pane zooms a pane.""" + window = mcp_session.active_window + window.split() + pane = window.active_pane + assert pane is not None + result = resize_pane( + pane_id=pane.pane_id, + zoom=True, + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert data["pane_id"] == pane.pane_id + + +def test_resize_pane_zoom_mutual_exclusivity( + mcp_server: Server, mcp_pane: Pane +) -> None: + """resize_pane raises ToolError when zoom combined with dimensions.""" + with pytest.raises(ToolError, match="Cannot combine zoom"): + resize_pane( + pane_id=mcp_pane.pane_id, + zoom=True, + height=10, + socket_name=mcp_server.socket_name, + ) + + def test_kill_pane(mcp_server: Server, mcp_session: Session) -> None: """kill_pane kills a pane.""" window = mcp_session.active_window From 490e53a347fb0dc178766241afc777583a060b17 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 11:22:43 -0500 Subject: [PATCH 07/47] mcp(fix[pane_tools]): Use pane.kill() instead of raw cmd why: Raw pane.cmd("kill-pane") skips stderr checking and structured logging that Pane.kill() provides. what: - Replace pane.cmd("kill-pane") with pane.kill() --- src/libtmux/mcp/tools/pane_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index 7105e487b..bc1700dc7 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -203,7 +203,7 @@ def kill_pane( window_id=window_id, ) pid = pane.pane_id - pane.cmd("kill-pane") + pane.kill() return f"Pane killed: {pid}" From 29f3ad8e5ad924387a42b2b14cdf1e2cd1aabc1f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 11:23:13 -0500 Subject: [PATCH 08/47] mcp(fix[window_tools]): Use Window.resize() instead of raw cmd why: Raw window.cmd("resize-window") skips stderr checking and self.refresh() that Window.resize() provides. what: - Replace raw cmd with window.resize(height=height, width=width) - Add test for resize_window tool --- src/libtmux/mcp/tools/window_tools.py | 8 +------- tests/mcp/test_window_tools.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/libtmux/mcp/tools/window_tools.py b/src/libtmux/mcp/tools/window_tools.py index 9e0823e4f..7f3710c80 100644 --- a/src/libtmux/mcp/tools/window_tools.py +++ b/src/libtmux/mcp/tools/window_tools.py @@ -300,13 +300,7 @@ def resize_window( session_name=session_name, session_id=session_id, ) - cmd_args: list[str] = [] - if width is not None: - cmd_args.extend(["-x", str(width)]) - if height is not None: - cmd_args.extend(["-y", str(height)]) - if cmd_args: - window.cmd("resize-window", *cmd_args) + window.resize(height=height, width=width) return json.dumps(_serialize_window(window)) diff --git a/tests/mcp/test_window_tools.py b/tests/mcp/test_window_tools.py index 3facfa5b0..fd21ed14a 100644 --- a/tests/mcp/test_window_tools.py +++ b/tests/mcp/test_window_tools.py @@ -9,6 +9,7 @@ kill_window, list_panes, rename_window, + resize_window, select_layout, split_window, ) @@ -81,6 +82,19 @@ def test_select_layout(mcp_server: Server, mcp_session: Session) -> None: assert "window_id" in data +def test_resize_window(mcp_server: Server, mcp_session: Session) -> None: + """resize_window resizes a window.""" + window = mcp_session.active_window + result = resize_window( + window_id=window.window_id, + height=20, + width=60, + socket_name=mcp_server.socket_name, + ) + data = json.loads(result) + assert data["window_id"] == window.window_id + + def test_kill_window(mcp_server: Server, mcp_session: Session) -> None: """kill_window kills a window.""" new_window = mcp_session.new_window(window_name="mcp_kill_win") From e4d23b507d9e41675228a97497cae0f2be18511b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 11:24:05 -0500 Subject: [PATCH 09/47] mcp(fix[window_tools,session_tools]): Validate direction parameters why: split_window silently ignored invalid directions (fell to default), create_window raised KeyError surfaced as "Unexpected error". what: - split_window: check _DIRECTION_MAP.get() result, raise ToolError if None - create_window: use .get() with explicit ToolError on invalid direction - Add tests for invalid direction in both tools --- src/libtmux/mcp/tools/session_tools.py | 9 ++++++++- src/libtmux/mcp/tools/window_tools.py | 6 ++++++ tests/mcp/test_session_tools.py | 16 ++++++++++++++++ tests/mcp/test_window_tools.py | 16 ++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/libtmux/mcp/tools/session_tools.py b/src/libtmux/mcp/tools/session_tools.py index 959b3d6e3..d3c111ba3 100644 --- a/src/libtmux/mcp/tools/session_tools.py +++ b/src/libtmux/mcp/tools/session_tools.py @@ -93,7 +93,14 @@ def create_window( "before": WindowDirection.Before, "after": WindowDirection.After, } - kwargs["direction"] = direction_map[direction.lower()] + resolved = direction_map.get(direction.lower()) + if resolved is None: + from fastmcp.exceptions import ToolError + + valid = ", ".join(sorted(direction_map)) + msg = f"Invalid direction: {direction!r}. Valid: {valid}" + raise ToolError(msg) + kwargs["direction"] = resolved window = session.new_window(**kwargs) return json.dumps(_serialize_window(window)) diff --git a/src/libtmux/mcp/tools/window_tools.py b/src/libtmux/mcp/tools/window_tools.py index 7f3710c80..d1f452d41 100644 --- a/src/libtmux/mcp/tools/window_tools.py +++ b/src/libtmux/mcp/tools/window_tools.py @@ -121,6 +121,12 @@ def split_window( pane_dir: PaneDirection | None = None if direction is not None: pane_dir = _DIRECTION_MAP.get(direction.lower()) + if pane_dir is None: + from fastmcp.exceptions import ToolError + + valid = ", ".join(sorted(_DIRECTION_MAP)) + msg = f"Invalid direction: {direction!r}. Valid: {valid}" + raise ToolError(msg) new_pane = window.split( direction=pane_dir, diff --git a/tests/mcp/test_session_tools.py b/tests/mcp/test_session_tools.py index e831fb060..98ce97d55 100644 --- a/tests/mcp/test_session_tools.py +++ b/tests/mcp/test_session_tools.py @@ -5,6 +5,9 @@ import json import typing as t +import pytest +from fastmcp.exceptions import ToolError + from libtmux.mcp.tools.session_tools import ( create_window, kill_session, @@ -50,6 +53,19 @@ def test_create_window(mcp_server: Server, mcp_session: Session) -> None: assert data["window_name"] == "mcp_test_win" +def test_create_window_invalid_direction( + mcp_server: Server, mcp_session: Session +) -> None: + """create_window raises ToolError on invalid direction.""" + with pytest.raises(ToolError, match="Invalid direction"): + create_window( + session_name=mcp_session.session_name, + window_name="bad_dir", + direction="sideways", + socket_name=mcp_server.socket_name, + ) + + def test_rename_session(mcp_server: Server, mcp_session: Session) -> None: """rename_session renames an existing session.""" original_name = mcp_session.session_name diff --git a/tests/mcp/test_window_tools.py b/tests/mcp/test_window_tools.py index fd21ed14a..3b454497a 100644 --- a/tests/mcp/test_window_tools.py +++ b/tests/mcp/test_window_tools.py @@ -5,6 +5,9 @@ import json import typing as t +import pytest +from fastmcp.exceptions import ToolError + from libtmux.mcp.tools.window_tools import ( kill_window, list_panes, @@ -57,6 +60,19 @@ def test_split_window_with_direction(mcp_server: Server, mcp_session: Session) - assert "pane_id" in data +def test_split_window_invalid_direction( + mcp_server: Server, mcp_session: Session +) -> None: + """split_window raises ToolError on invalid direction.""" + window = mcp_session.active_window + with pytest.raises(ToolError, match="Invalid direction"): + split_window( + window_id=window.window_id, + direction="diagonal", + socket_name=mcp_server.socket_name, + ) + + def test_rename_window(mcp_server: Server, mcp_session: Session) -> None: """rename_window renames a window.""" window = mcp_session.active_window From 2eed379d390283f8a7fbdab5653e55a9ebffb57f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 11:24:36 -0500 Subject: [PATCH 10/47] mcp(fix[option_tools]): Validate scope parameter why: Invalid scope silently fell through to server scope, making it impossible for users to detect typos like "global" vs "server". what: - Check _SCOPE_MAP.get() result, raise ToolError if scope is invalid - Add test for invalid scope --- src/libtmux/mcp/tools/option_tools.py | 7 +++++++ tests/mcp/test_option_tools.py | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/libtmux/mcp/tools/option_tools.py b/src/libtmux/mcp/tools/option_tools.py index 6261f0930..de0eda185 100644 --- a/src/libtmux/mcp/tools/option_tools.py +++ b/src/libtmux/mcp/tools/option_tools.py @@ -36,6 +36,13 @@ def _resolve_option_target( server = _get_server(socket_name=socket_name) opt_scope = _SCOPE_MAP.get(scope) if scope is not None else None + if scope is not None and opt_scope is None: + from fastmcp.exceptions import ToolError + + valid = ", ".join(sorted(_SCOPE_MAP)) + msg = f"Invalid scope: {scope!r}. Valid: {valid}" + raise ToolError(msg) + if target is not None and opt_scope is not None: if opt_scope == OptionScope.Session: return _resolve_session(server, session_name=target), opt_scope diff --git a/tests/mcp/test_option_tools.py b/tests/mcp/test_option_tools.py index b51793f0c..66b793d5b 100644 --- a/tests/mcp/test_option_tools.py +++ b/tests/mcp/test_option_tools.py @@ -5,6 +5,9 @@ import json import typing as t +import pytest +from fastmcp.exceptions import ToolError + from libtmux.mcp.tools.option_tools import set_option, show_option if t.TYPE_CHECKING: @@ -25,6 +28,16 @@ def test_show_option(mcp_server: Server, mcp_session: Session) -> None: assert "value" in data +def test_show_option_invalid_scope(mcp_server: Server, mcp_session: Session) -> None: + """show_option raises ToolError on invalid scope.""" + with pytest.raises(ToolError, match="Invalid scope"): + show_option( + option="base-index", + scope="global", + socket_name=mcp_server.socket_name, + ) + + def test_set_option(mcp_server: Server, mcp_session: Session) -> None: """set_option sets a tmux option.""" result = set_option( From 5955533b6a28351f81e965b35a22958640f5296f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 11:25:13 -0500 Subject: [PATCH 11/47] mcp(fix[hierarchy]): Remove broad try/except, let FastMCP handle errors why: Broad except Exception blocks caught all errors and returned them as content strings, hiding real errors from the MCP client. FastMCP natively converts unhandled exceptions to ResourceError. what: - Remove all 6 try/except Exception blocks from resource functions - Raise ValueError for not-found sessions/windows/panes - Remove unused logger import --- src/libtmux/mcp/resources/hierarchy.py | 109 +++++++++++-------------- 1 file changed, 47 insertions(+), 62 deletions(-) diff --git a/src/libtmux/mcp/resources/hierarchy.py b/src/libtmux/mcp/resources/hierarchy.py index 36062a4f3..527a1822a 100644 --- a/src/libtmux/mcp/resources/hierarchy.py +++ b/src/libtmux/mcp/resources/hierarchy.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import logging import typing as t from libtmux.mcp._utils import ( @@ -16,8 +15,6 @@ if t.TYPE_CHECKING: from fastmcp import FastMCP -logger = logging.getLogger(__name__) - def register(mcp: FastMCP) -> None: """Register hierarchy resources with the FastMCP instance.""" @@ -31,12 +28,9 @@ def get_sessions() -> str: str JSON array of session objects. """ - try: - server = _get_server() - sessions = [_serialize_session(s) for s in server.sessions] - return json.dumps(sessions, indent=2) - except Exception as e: - return json.dumps({"error": str(e)}) + server = _get_server() + sessions = [_serialize_session(s) for s in server.sessions] + return json.dumps(sessions, indent=2) @mcp.resource("tmux://sessions/{session_name}") def get_session(session_name: str) -> str: @@ -52,17 +46,15 @@ def get_session(session_name: str) -> str: str JSON object with session info and its windows. """ - try: - server = _get_server() - session = server.sessions.get(session_name=session_name, default=None) - if session is None: - return json.dumps({"error": f"Session not found: {session_name}"}) - - result = _serialize_session(session) - result["windows"] = [_serialize_window(w) for w in session.windows] - return json.dumps(result, indent=2) - except Exception as e: - return json.dumps({"error": str(e)}) + server = _get_server() + session = server.sessions.get(session_name=session_name, default=None) + if session is None: + msg = f"Session not found: {session_name}" + raise ValueError(msg) + + result = _serialize_session(session) + result["windows"] = [_serialize_window(w) for w in session.windows] + return json.dumps(result, indent=2) @mcp.resource("tmux://sessions/{session_name}/windows") def get_session_windows(session_name: str) -> str: @@ -78,16 +70,14 @@ def get_session_windows(session_name: str) -> str: str JSON array of window objects. """ - try: - server = _get_server() - session = server.sessions.get(session_name=session_name, default=None) - if session is None: - return json.dumps({"error": f"Session not found: {session_name}"}) + server = _get_server() + session = server.sessions.get(session_name=session_name, default=None) + if session is None: + msg = f"Session not found: {session_name}" + raise ValueError(msg) - windows = [_serialize_window(w) for w in session.windows] - return json.dumps(windows, indent=2) - except Exception as e: - return json.dumps({"error": str(e)}) + windows = [_serialize_window(w) for w in session.windows] + return json.dumps(windows, indent=2) @mcp.resource("tmux://sessions/{session_name}/windows/{window_index}") def get_window(session_name: str, window_index: str) -> str: @@ -105,21 +95,20 @@ def get_window(session_name: str, window_index: str) -> str: str JSON object with window info and its panes. """ - try: - server = _get_server() - session = server.sessions.get(session_name=session_name, default=None) - if session is None: - return json.dumps({"error": f"Session not found: {session_name}"}) - - window = session.windows.get(window_index=window_index, default=None) - if window is None: - return json.dumps({"error": f"Window not found: index {window_index}"}) - - result = _serialize_window(window) - result["panes"] = [_serialize_pane(p) for p in window.panes] - return json.dumps(result, indent=2) - except Exception as e: - return json.dumps({"error": str(e)}) + server = _get_server() + session = server.sessions.get(session_name=session_name, default=None) + if session is None: + msg = f"Session not found: {session_name}" + raise ValueError(msg) + + window = session.windows.get(window_index=window_index, default=None) + if window is None: + msg = f"Window not found: index {window_index}" + raise ValueError(msg) + + result = _serialize_window(window) + result["panes"] = [_serialize_pane(p) for p in window.panes] + return json.dumps(result, indent=2) @mcp.resource("tmux://panes/{pane_id}") def get_pane(pane_id: str) -> str: @@ -135,15 +124,13 @@ def get_pane(pane_id: str) -> str: str JSON object of pane details. """ - try: - server = _get_server() - pane = server.panes.get(pane_id=pane_id, default=None) - if pane is None: - return json.dumps({"error": f"Pane not found: {pane_id}"}) + server = _get_server() + pane = server.panes.get(pane_id=pane_id, default=None) + if pane is None: + msg = f"Pane not found: {pane_id}" + raise ValueError(msg) - return json.dumps(_serialize_pane(pane), indent=2) - except Exception as e: - return json.dumps({"error": str(e)}) + return json.dumps(_serialize_pane(pane), indent=2) @mcp.resource("tmux://panes/{pane_id}/content") def get_pane_content(pane_id: str) -> str: @@ -159,13 +146,11 @@ def get_pane_content(pane_id: str) -> str: str Plain text captured pane content. """ - try: - server = _get_server() - pane = server.panes.get(pane_id=pane_id, default=None) - if pane is None: - return f"Error: Pane not found: {pane_id}" - - lines = pane.capture_pane() - return "\n".join(lines) - except Exception as e: - return f"Error: {e}" + server = _get_server() + pane = server.panes.get(pane_id=pane_id, default=None) + if pane is None: + msg = f"Pane not found: {pane_id}" + raise ValueError(msg) + + lines = pane.capture_pane() + return "\n".join(lines) From 84eb7ad495270e5fe052cb70d84d6f04aa4d4211 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 11:25:39 -0500 Subject: [PATCH 12/47] mcp(fix[_utils]): Log unexpected errors in handle_tool_errors why: Generic exceptions were re-raised as ToolError without logging, making it impossible to diagnose unexpected errors in server logs. what: - Add logger.exception() before re-raising generic Exception as ToolError --- src/libtmux/mcp/_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libtmux/mcp/_utils.py b/src/libtmux/mcp/_utils.py index a76ccf599..85689ad37 100644 --- a/src/libtmux/mcp/_utils.py +++ b/src/libtmux/mcp/_utils.py @@ -387,6 +387,7 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: msg = f"tmux error: {e}" raise ToolError(msg) from e except Exception as e: + logger.exception("unexpected error in MCP tool %s", fn.__name__) msg = f"Unexpected error: {type(e).__name__}: {e}" raise ToolError(msg) from e From a9c9c61ad3df058b431ae29ee1e368ba58d6a1e4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 12:22:07 -0500 Subject: [PATCH 13/47] mcp(feat[_utils]): Add _apply_filters() helper for QueryList filtering why: MCP list tools need to expose libtmux's QueryList filtering via an optional dict parameter, requiring a bridge between MCP dict params and QueryList.filter(**kwargs). what: - Add _apply_filters() that validates operator keys against LOOKUP_NAME_MAP - Raise ToolError with valid operators list on invalid lookup operator - Short-circuit to direct serialization when filters is None/empty - Add 6 parametrized tests: none, empty, exact, no_match, invalid_op, contains --- src/libtmux/mcp/_utils.py | 48 ++++++++++++++++++ tests/mcp/test_utils.py | 101 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/src/libtmux/mcp/_utils.py b/src/libtmux/mcp/_utils.py index 85689ad37..2a02b0ab1 100644 --- a/src/libtmux/mcp/_utils.py +++ b/src/libtmux/mcp/_utils.py @@ -12,6 +12,7 @@ import typing as t from libtmux import exc +from libtmux._internal.query_list import LOOKUP_NAME_MAP from libtmux.server import Server if t.TYPE_CHECKING: @@ -273,6 +274,53 @@ def _resolve_pane( return panes[0] +def _apply_filters( + items: t.Any, + filters: dict[str, str] | None, + serializer: t.Callable[..., dict[str, t.Any]], +) -> list[dict[str, t.Any]]: + """Apply QueryList filters and serialize results. + + Parameters + ---------- + items : QueryList + The QueryList of tmux objects to filter. + filters : dict or None + Django-style filter kwargs (e.g. ``{"session_name__contains": "dev"}``). + If None or empty, all items are returned. + serializer : callable + Serializer function to convert each item to a dict. + + Returns + ------- + list[dict] + Serialized list of matching items. + + Raises + ------ + ToolError + If a filter key uses an invalid lookup operator. + """ + if not filters: + return [serializer(item) for item in items] + + from fastmcp.exceptions import ToolError + + valid_ops = sorted(LOOKUP_NAME_MAP.keys()) + for key in filters: + if "__" in key: + _field, op = key.rsplit("__", 1) + if op not in LOOKUP_NAME_MAP: + msg = ( + f"Invalid filter operator '{op}' in '{key}'. " + f"Valid operators: {', '.join(valid_ops)}" + ) + raise ToolError(msg) + + filtered = items.filter(**filters) + return [serializer(item) for item in filtered] + + def _serialize_session(session: Session) -> dict[str, t.Any]: """Serialize a Session to a JSON-compatible dict. diff --git a/tests/mcp/test_utils.py b/tests/mcp/test_utils.py index 642ba8827..6d74365e0 100644 --- a/tests/mcp/test_utils.py +++ b/tests/mcp/test_utils.py @@ -5,9 +5,11 @@ import typing as t import pytest +from fastmcp.exceptions import ToolError from libtmux import exc from libtmux.mcp._utils import ( + _apply_filters, _get_server, _invalidate_server, _resolve_pane, @@ -144,3 +146,102 @@ def test_invalidate_server() -> None: assert len(_server_cache) == 1 _invalidate_server(socket_name="test_inv") assert len(_server_cache) == 0 + + +class ApplyFiltersFixture(t.NamedTuple): + """Test fixture for _apply_filters.""" + + test_id: str + filters: dict[str, str] | None + expected_count: int | None # None = don't check exact count + expect_error: bool + error_match: str | None + + +APPLY_FILTERS_FIXTURES: list[ApplyFiltersFixture] = [ + ApplyFiltersFixture( + test_id="none_returns_all", + filters=None, + expected_count=None, + expect_error=False, + error_match=None, + ), + ApplyFiltersFixture( + test_id="empty_dict_returns_all", + filters={}, + expected_count=None, + expect_error=False, + error_match=None, + ), + ApplyFiltersFixture( + test_id="exact_match", + filters={"session_name": ""}, + expected_count=1, + expect_error=False, + error_match=None, + ), + ApplyFiltersFixture( + test_id="no_match_returns_empty", + filters={"session_name": "nonexistent_xyz_999"}, + expected_count=0, + expect_error=False, + error_match=None, + ), + ApplyFiltersFixture( + test_id="invalid_operator", + filters={"session_name__badop": "test"}, + expected_count=None, + expect_error=True, + error_match="Invalid filter operator", + ), + ApplyFiltersFixture( + test_id="contains_operator", + filters={"session_name__contains": ""}, + expected_count=1, + expect_error=False, + error_match=None, + ), +] + + +@pytest.mark.parametrize( + ApplyFiltersFixture._fields, + APPLY_FILTERS_FIXTURES, + ids=[f.test_id for f in APPLY_FILTERS_FIXTURES], +) +def test_apply_filters( + mcp_server: Server, + mcp_session: Session, + test_id: str, + filters: dict[str, str] | None, + expected_count: int | None, + expect_error: bool, + error_match: str | None, +) -> None: + """_apply_filters bridges dict params to QueryList.filter().""" + # Substitute placeholders with real session name + if filters is not None: + session_name = mcp_session.session_name + assert session_name is not None + resolved: dict[str, str] = {} + for k, v in filters.items(): + if v == "": + resolved[k] = session_name + elif v == "": + resolved[k] = session_name[:4] + else: + resolved[k] = v + filters = resolved + + sessions = mcp_server.sessions + + if expect_error: + with pytest.raises(ToolError, match=error_match): + _apply_filters(sessions, filters, _serialize_session) + else: + result = _apply_filters(sessions, filters, _serialize_session) + assert isinstance(result, list) + if expected_count is not None: + assert len(result) == expected_count + else: + assert len(result) >= 1 From 1dee773f80eb2ba0a39d6c04508771dc2bc5e8db Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 12:22:17 -0500 Subject: [PATCH 14/47] mcp(feat[list_tools]): Add filtering and scope broadening to list tools why: LLM agents need to search across tmux objects without knowing the exact hierarchy, and filter results using QueryList's 12 lookup operators. what: - Add optional filters param to list_sessions, list_windows, list_panes - Broaden list_windows scope: omit session params to list all server windows - Broaden list_panes scope: window > session > server fallback chain - Add 9 parametrized tests for list_sessions filtering - Add 7 parametrized tests for list_windows filtering + cross-session scope - Add 7 parametrized tests for list_panes filtering + session/server scope --- src/libtmux/mcp/tools/server_tools.py | 10 +- src/libtmux/mcp/tools/session_tools.py | 20 +++- src/libtmux/mcp/tools/window_tools.py | 42 +++++--- tests/mcp/test_server_tools.py | 128 +++++++++++++++++++++++++ tests/mcp/test_session_tools.py | 120 +++++++++++++++++++++++ tests/mcp/test_window_tools.py | 99 +++++++++++++++++++ 6 files changed, 399 insertions(+), 20 deletions(-) diff --git a/src/libtmux/mcp/tools/server_tools.py b/src/libtmux/mcp/tools/server_tools.py index a6ca04ebc..c911e450e 100644 --- a/src/libtmux/mcp/tools/server_tools.py +++ b/src/libtmux/mcp/tools/server_tools.py @@ -6,6 +6,7 @@ import typing as t from libtmux.mcp._utils import ( + _apply_filters, _get_server, _invalidate_server, _serialize_session, @@ -17,13 +18,18 @@ @handle_tool_errors -def list_sessions(socket_name: str | None = None) -> str: +def list_sessions( + socket_name: str | None = None, + filters: dict[str, str] | None = None, +) -> str: """List all tmux sessions. Parameters ---------- socket_name : str, optional tmux socket name. Defaults to LIBTMUX_SOCKET env var. + filters : dict, optional + Django-style filters (e.g. ``{"session_name__contains": "dev"}``). Returns ------- @@ -32,7 +38,7 @@ def list_sessions(socket_name: str | None = None) -> str: """ server = _get_server(socket_name=socket_name) sessions = server.sessions - return json.dumps([_serialize_session(s) for s in sessions]) + return json.dumps(_apply_filters(sessions, filters, _serialize_session)) @handle_tool_errors diff --git a/src/libtmux/mcp/tools/session_tools.py b/src/libtmux/mcp/tools/session_tools.py index d3c111ba3..0b5b8be2d 100644 --- a/src/libtmux/mcp/tools/session_tools.py +++ b/src/libtmux/mcp/tools/session_tools.py @@ -7,6 +7,7 @@ from libtmux.constants import WindowDirection from libtmux.mcp._utils import ( + _apply_filters, _get_server, _resolve_session, _serialize_session, @@ -23,17 +24,21 @@ def list_windows( session_name: str | None = None, session_id: str | None = None, socket_name: str | None = None, + filters: dict[str, str] | None = None, ) -> str: - """List all windows in a tmux session. + """List windows in a tmux session, or all windows across sessions. Parameters ---------- session_name : str, optional - Session name to look up. + Session name to look up. If omitted along with session_id, + returns windows from all sessions. session_id : str, optional Session ID (e.g. '$1') to look up. socket_name : str, optional tmux socket name. Defaults to LIBTMUX_SOCKET env var. + filters : dict, optional + Django-style filters (e.g. ``{"window_name__contains": "dev"}``). Returns ------- @@ -41,9 +46,14 @@ def list_windows( JSON array of window objects. """ server = _get_server(socket_name=socket_name) - session = _resolve_session(server, session_name=session_name, session_id=session_id) - windows = session.windows - return json.dumps([_serialize_window(w) for w in windows]) + if session_name is not None or session_id is not None: + session = _resolve_session( + server, session_name=session_name, session_id=session_id + ) + windows = session.windows + else: + windows = server.windows + return json.dumps(_apply_filters(windows, filters, _serialize_window)) @handle_tool_errors diff --git a/src/libtmux/mcp/tools/window_tools.py b/src/libtmux/mcp/tools/window_tools.py index d1f452d41..5591eed2d 100644 --- a/src/libtmux/mcp/tools/window_tools.py +++ b/src/libtmux/mcp/tools/window_tools.py @@ -7,8 +7,10 @@ from libtmux.constants import PaneDirection from libtmux.mcp._utils import ( + _apply_filters, _get_server, _resolve_pane, + _resolve_session, _resolve_window, _serialize_pane, _serialize_window, @@ -33,21 +35,26 @@ def list_panes( window_id: str | None = None, window_index: str | None = None, socket_name: str | None = None, + filters: dict[str, str] | None = None, ) -> str: - """List all panes in a tmux window. + """List panes in a tmux window, session, or across the entire server. Parameters ---------- session_name : str, optional - Session name to resolve the window from. + Session name. If given without window params, lists all panes + in the session. session_id : str, optional - Session ID to resolve the window from. + Session ID. If given without window params, lists all panes + in the session. window_id : str, optional - Window ID (e.g. '@1'). + Window ID (e.g. '@1'). Scopes to a single window. window_index : str, optional - Window index within the session. + Window index within the session. Scopes to a single window. socket_name : str, optional tmux socket name. + filters : dict, optional + Django-style filters (e.g. ``{"pane_current_command__contains": "vim"}``). Returns ------- @@ -55,14 +62,23 @@ def list_panes( JSON array of serialized pane objects. """ server = _get_server(socket_name=socket_name) - window = _resolve_window( - server, - window_id=window_id, - window_index=window_index, - session_name=session_name, - session_id=session_id, - ) - return json.dumps([_serialize_pane(p) for p in window.panes]) + if window_id is not None or window_index is not None: + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + session_id=session_id, + ) + panes = window.panes + elif session_name is not None or session_id is not None: + session = _resolve_session( + server, session_name=session_name, session_id=session_id + ) + panes = session.panes + else: + panes = server.panes + return json.dumps(_apply_filters(panes, filters, _serialize_pane)) @handle_tool_errors diff --git a/tests/mcp/test_server_tools.py b/tests/mcp/test_server_tools.py index 944bdd7d4..5ecd541ad 100644 --- a/tests/mcp/test_server_tools.py +++ b/tests/mcp/test_server_tools.py @@ -69,6 +69,134 @@ def test_get_server_info(mcp_server: Server, mcp_session: Session) -> None: assert data["session_count"] >= 1 +class ListSessionsFilterFixture(t.NamedTuple): + """Test fixture for list_sessions with filters.""" + + test_id: str + filters: dict[str, str] | None + expected_count: int | None + expect_error: bool + error_match: str | None + + +LIST_SESSIONS_FILTER_FIXTURES: list[ListSessionsFilterFixture] = [ + ListSessionsFilterFixture( + test_id="no_filters", + filters=None, + expected_count=None, + expect_error=False, + error_match=None, + ), + ListSessionsFilterFixture( + test_id="exact_session_name", + filters={"session_name": ""}, + expected_count=1, + expect_error=False, + error_match=None, + ), + ListSessionsFilterFixture( + test_id="contains_operator", + filters={"session_name__contains": ""}, + expected_count=1, + expect_error=False, + error_match=None, + ), + ListSessionsFilterFixture( + test_id="startswith_operator", + filters={"session_name__startswith": ""}, + expected_count=None, + expect_error=False, + error_match=None, + ), + ListSessionsFilterFixture( + test_id="regex_operator", + filters={"session_name__regex": ".*"}, + expected_count=None, + expect_error=False, + error_match=None, + ), + ListSessionsFilterFixture( + test_id="icontains_operator", + filters={"session_name__icontains": ""}, + expected_count=1, + expect_error=False, + error_match=None, + ), + ListSessionsFilterFixture( + test_id="no_match", + filters={"session_name": "nonexistent_xyz_999"}, + expected_count=0, + expect_error=False, + error_match=None, + ), + ListSessionsFilterFixture( + test_id="invalid_operator", + filters={"session_name__badop": "test"}, + expected_count=None, + expect_error=True, + error_match="Invalid filter operator", + ), + ListSessionsFilterFixture( + test_id="multiple_filters", + filters={"session_name__contains": "", "session_name__regex": ".*"}, + expected_count=None, + expect_error=False, + error_match=None, + ), +] + + +@pytest.mark.parametrize( + ListSessionsFilterFixture._fields, + LIST_SESSIONS_FILTER_FIXTURES, + ids=[f.test_id for f in LIST_SESSIONS_FILTER_FIXTURES], +) +def test_list_sessions_with_filters( + mcp_server: Server, + mcp_session: Session, + test_id: str, + filters: dict[str, str] | None, + expected_count: int | None, + expect_error: bool, + error_match: str | None, +) -> None: + """list_sessions supports QueryList filtering.""" + from fastmcp.exceptions import ToolError + + if filters is not None: + session_name = mcp_session.session_name + assert session_name is not None + resolved: dict[str, str] = {} + for k, v in filters.items(): + if v == "": + resolved[k] = session_name + elif v == "": + resolved[k] = session_name[:4] + elif v == "": + resolved[k] = session_name[:4].upper() + else: + resolved[k] = v + filters = resolved + + if expect_error: + with pytest.raises(ToolError, match=error_match): + list_sessions( + socket_name=mcp_server.socket_name, + filters=filters, + ) + else: + result = list_sessions( + socket_name=mcp_server.socket_name, + filters=filters, + ) + data = json.loads(result) + assert isinstance(data, list) + if expected_count is not None: + assert len(data) == expected_count + else: + assert len(data) >= 1 + + def test_kill_server(mcp_server: Server, mcp_session: Session) -> None: """kill_server kills the tmux server.""" result = kill_server(socket_name=mcp_server.socket_name) diff --git a/tests/mcp/test_session_tools.py b/tests/mcp/test_session_tools.py index 98ce97d55..95f37a83f 100644 --- a/tests/mcp/test_session_tools.py +++ b/tests/mcp/test_session_tools.py @@ -78,6 +78,126 @@ def test_rename_session(mcp_server: Server, mcp_session: Session) -> None: assert data["session_name"] == "mcp_renamed" +class ListWindowsFilterFixture(t.NamedTuple): + """Test fixture for list_windows with filters.""" + + test_id: str + provide_session: bool + filters: dict[str, str] | None + expected_min_count: int + expect_error: bool + + +LIST_WINDOWS_FILTER_FIXTURES: list[ListWindowsFilterFixture] = [ + ListWindowsFilterFixture( + test_id="no_filters_scoped", + provide_session=True, + filters=None, + expected_min_count=1, + expect_error=False, + ), + ListWindowsFilterFixture( + test_id="no_filters_all_sessions", + provide_session=False, + filters=None, + expected_min_count=1, + expect_error=False, + ), + ListWindowsFilterFixture( + test_id="filter_by_name", + provide_session=True, + filters={"window_name": ""}, + expected_min_count=1, + expect_error=False, + ), + ListWindowsFilterFixture( + test_id="filter_by_name_contains", + provide_session=False, + filters={"window_name__contains": ""}, + expected_min_count=1, + expect_error=False, + ), + ListWindowsFilterFixture( + test_id="filter_active", + provide_session=True, + filters={"window_active": "1"}, + expected_min_count=1, + expect_error=False, + ), + ListWindowsFilterFixture( + test_id="invalid_operator", + provide_session=True, + filters={"window_name__badop": "test"}, + expected_min_count=0, + expect_error=True, + ), + ListWindowsFilterFixture( + test_id="cross_session_filter", + provide_session=False, + filters={"window_name": ""}, + expected_min_count=1, + expect_error=False, + ), +] + + +@pytest.mark.parametrize( + ListWindowsFilterFixture._fields, + LIST_WINDOWS_FILTER_FIXTURES, + ids=[f.test_id for f in LIST_WINDOWS_FILTER_FIXTURES], +) +def test_list_windows_with_filters( + mcp_server: Server, + mcp_session: Session, + test_id: str, + provide_session: bool, + filters: dict[str, str] | None, + expected_min_count: int, + expect_error: bool, +) -> None: + """list_windows supports QueryList filtering and scope broadening.""" + # Create a second session with a named window for cross-session tests + second_session = mcp_server.new_session(session_name="mcp_filter_second") + cross_win = second_session.new_window(window_name="cross_target_win") + + window = mcp_session.active_window + window_name = window.window_name + assert window_name is not None + + if filters is not None: + resolved: dict[str, str] = {} + for k, v in filters.items(): + if v == "": + resolved[k] = window_name + elif v == "": + resolved[k] = window_name[:3] + elif v == "": + resolved[k] = "cross_target_win" + else: + resolved[k] = v + filters = resolved + + kwargs: dict[str, t.Any] = { + "socket_name": mcp_server.socket_name, + "filters": filters, + } + if provide_session: + kwargs["session_name"] = mcp_session.session_name + + if expect_error: + with pytest.raises(ToolError, match="Invalid filter operator"): + list_windows(**kwargs) + else: + result = list_windows(**kwargs) + data = json.loads(result) + assert isinstance(data, list) + assert len(data) >= expected_min_count + + # Cleanup + cross_win.kill() + second_session.kill() + + def test_kill_session(mcp_server: Server) -> None: """kill_session kills a session.""" mcp_server.new_session(session_name="mcp_kill_me") diff --git a/tests/mcp/test_window_tools.py b/tests/mcp/test_window_tools.py index 3b454497a..69bb77641 100644 --- a/tests/mcp/test_window_tools.py +++ b/tests/mcp/test_window_tools.py @@ -111,6 +111,105 @@ def test_resize_window(mcp_server: Server, mcp_session: Session) -> None: assert data["window_id"] == window.window_id +class ListPanesFilterFixture(t.NamedTuple): + """Test fixture for list_panes with filters.""" + + test_id: str + scope: str # "window", "session", "server" + filters: dict[str, str] | None + expected_min_count: int + expect_error: bool + + +LIST_PANES_FILTER_FIXTURES: list[ListPanesFilterFixture] = [ + ListPanesFilterFixture( + test_id="window_scope_no_filter", + scope="window", + filters=None, + expected_min_count=1, + expect_error=False, + ), + ListPanesFilterFixture( + test_id="session_scope_no_filter", + scope="session", + filters=None, + expected_min_count=1, + expect_error=False, + ), + ListPanesFilterFixture( + test_id="server_scope_no_filter", + scope="server", + filters=None, + expected_min_count=1, + expect_error=False, + ), + ListPanesFilterFixture( + test_id="filter_active_pane", + scope="window", + filters={"pane_active": "1"}, + expected_min_count=1, + expect_error=False, + ), + ListPanesFilterFixture( + test_id="filter_by_command_contains", + scope="server", + filters={"pane_current_command__regex": ".*"}, + expected_min_count=1, + expect_error=False, + ), + ListPanesFilterFixture( + test_id="invalid_operator", + scope="window", + filters={"pane_id__badop": "test"}, + expected_min_count=0, + expect_error=True, + ), + ListPanesFilterFixture( + test_id="session_scope_with_filter", + scope="session", + filters={"pane_active": "1"}, + expected_min_count=1, + expect_error=False, + ), +] + + +@pytest.mark.parametrize( + ListPanesFilterFixture._fields, + LIST_PANES_FILTER_FIXTURES, + ids=[f.test_id for f in LIST_PANES_FILTER_FIXTURES], +) +def test_list_panes_with_filters( + mcp_server: Server, + mcp_session: Session, + test_id: str, + scope: str, + filters: dict[str, str] | None, + expected_min_count: int, + expect_error: bool, +) -> None: + """list_panes supports QueryList filtering and scope broadening.""" + window = mcp_session.active_window + + kwargs: dict[str, t.Any] = { + "socket_name": mcp_server.socket_name, + "filters": filters, + } + if scope == "window": + kwargs["window_id"] = window.window_id + elif scope == "session": + kwargs["session_name"] = mcp_session.session_name + + if expect_error: + with pytest.raises(ToolError, match="Invalid filter operator"): + list_panes(**kwargs) + else: + result = list_panes(**kwargs) + data = json.loads(result) + assert isinstance(data, list) + assert len(data) >= expected_min_count + + def test_kill_window(mcp_server: Server, mcp_session: Session) -> None: """kill_window kills a window.""" new_window = mcp_session.new_window(window_name="mcp_kill_win") From ef0ab7d7a7a3c3d5340ba6fba9e9b43b44e9fe1f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 13:18:58 -0500 Subject: [PATCH 15/47] mcp(fix[_utils]): Accept filters as JSON string for MCP client compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Cursor's composer-1/composer-1.5 models and some other MCP clients cannot serialize nested dict tool arguments — they either stringify the object or fail with a JSON parse error before dispatching. Claude and GPT models through Cursor work fine; the bug is model-specific. refs: - https://forum.cursor.com/t/145807 (Dec 2025) - https://forum.cursor.com/t/132571 - https://forum.cursor.com/t/151180 (Feb 2026) - https://github.com/makenotion/notion-mcp-server/issues/176 (Jan 2026) - https://github.com/anthropics/claude-code/issues/5504 what: - Widen _apply_filters() to accept str, parse via json.loads() - Widen tool signatures to dict | str | None for JSON Schema compat - Add 5 parametrized test cases for string coercion and error paths --- src/libtmux/mcp/_utils.py | 23 +++++++++++-- src/libtmux/mcp/tools/server_tools.py | 7 ++-- src/libtmux/mcp/tools/session_tools.py | 7 ++-- src/libtmux/mcp/tools/window_tools.py | 8 +++-- tests/mcp/test_utils.py | 46 ++++++++++++++++++++++++-- 5 files changed, 76 insertions(+), 15 deletions(-) diff --git a/src/libtmux/mcp/_utils.py b/src/libtmux/mcp/_utils.py index 2a02b0ab1..b3bb3779e 100644 --- a/src/libtmux/mcp/_utils.py +++ b/src/libtmux/mcp/_utils.py @@ -7,6 +7,7 @@ from __future__ import annotations import functools +import json import logging import os import typing as t @@ -276,7 +277,7 @@ def _resolve_pane( def _apply_filters( items: t.Any, - filters: dict[str, str] | None, + filters: dict[str, str] | str | None, serializer: t.Callable[..., dict[str, t.Any]], ) -> list[dict[str, t.Any]]: """Apply QueryList filters and serialize results. @@ -285,8 +286,9 @@ def _apply_filters( ---------- items : QueryList The QueryList of tmux objects to filter. - filters : dict or None - Django-style filter kwargs (e.g. ``{"session_name__contains": "dev"}``). + filters : dict or str, optional + Django-style filters as a dict (e.g. ``{"session_name__contains": "dev"}``) + or as a JSON string. Some MCP clients require the string form. If None or empty, all items are returned. serializer : callable Serializer function to convert each item to a dict. @@ -306,6 +308,21 @@ def _apply_filters( from fastmcp.exceptions import ToolError + # Workaround: Cursor's composer-1/composer-1.5 models and some other + # MCP clients serialize dict params as JSON strings instead of objects. + # Claude and GPT models through Cursor work fine; the bug is model-specific. + # See: https://forum.cursor.com/t/145807 + # https://github.com/anthropics/claude-code/issues/5504 + if isinstance(filters, str): + try: + filters = json.loads(filters) + except (json.JSONDecodeError, ValueError) as e: + msg = f"Invalid filters JSON: {e}" + raise ToolError(msg) from e + if not isinstance(filters, dict): + msg = f"filters must be a JSON object, got {type(filters).__name__}" + raise ToolError(msg) from None + valid_ops = sorted(LOOKUP_NAME_MAP.keys()) for key in filters: if "__" in key: diff --git a/src/libtmux/mcp/tools/server_tools.py b/src/libtmux/mcp/tools/server_tools.py index c911e450e..08d46def7 100644 --- a/src/libtmux/mcp/tools/server_tools.py +++ b/src/libtmux/mcp/tools/server_tools.py @@ -20,7 +20,7 @@ @handle_tool_errors def list_sessions( socket_name: str | None = None, - filters: dict[str, str] | None = None, + filters: dict[str, str] | str | None = None, ) -> str: """List all tmux sessions. @@ -28,8 +28,9 @@ def list_sessions( ---------- socket_name : str, optional tmux socket name. Defaults to LIBTMUX_SOCKET env var. - filters : dict, optional - Django-style filters (e.g. ``{"session_name__contains": "dev"}``). + filters : dict or str, optional + Django-style filters as a dict (e.g. ``{"session_name__contains": "dev"}``) + or as a JSON string. Some MCP clients require the string form. Returns ------- diff --git a/src/libtmux/mcp/tools/session_tools.py b/src/libtmux/mcp/tools/session_tools.py index 0b5b8be2d..3c99665df 100644 --- a/src/libtmux/mcp/tools/session_tools.py +++ b/src/libtmux/mcp/tools/session_tools.py @@ -24,7 +24,7 @@ def list_windows( session_name: str | None = None, session_id: str | None = None, socket_name: str | None = None, - filters: dict[str, str] | None = None, + filters: dict[str, str] | str | None = None, ) -> str: """List windows in a tmux session, or all windows across sessions. @@ -37,8 +37,9 @@ def list_windows( Session ID (e.g. '$1') to look up. socket_name : str, optional tmux socket name. Defaults to LIBTMUX_SOCKET env var. - filters : dict, optional - Django-style filters (e.g. ``{"window_name__contains": "dev"}``). + filters : dict or str, optional + Django-style filters as a dict (e.g. ``{"window_name__contains": "dev"}``) + or as a JSON string. Some MCP clients require the string form. Returns ------- diff --git a/src/libtmux/mcp/tools/window_tools.py b/src/libtmux/mcp/tools/window_tools.py index 5591eed2d..821bb3541 100644 --- a/src/libtmux/mcp/tools/window_tools.py +++ b/src/libtmux/mcp/tools/window_tools.py @@ -35,7 +35,7 @@ def list_panes( window_id: str | None = None, window_index: str | None = None, socket_name: str | None = None, - filters: dict[str, str] | None = None, + filters: dict[str, str] | str | None = None, ) -> str: """List panes in a tmux window, session, or across the entire server. @@ -53,8 +53,10 @@ def list_panes( Window index within the session. Scopes to a single window. socket_name : str, optional tmux socket name. - filters : dict, optional - Django-style filters (e.g. ``{"pane_current_command__contains": "vim"}``). + filters : dict or str, optional + Django-style filters as a dict + (e.g. ``{"pane_current_command__contains": "vim"}``) + or as a JSON string. Some MCP clients require the string form. Returns ------- diff --git a/tests/mcp/test_utils.py b/tests/mcp/test_utils.py index 6d74365e0..4bea6f9a5 100644 --- a/tests/mcp/test_utils.py +++ b/tests/mcp/test_utils.py @@ -152,7 +152,7 @@ class ApplyFiltersFixture(t.NamedTuple): """Test fixture for _apply_filters.""" test_id: str - filters: dict[str, str] | None + filters: dict[str, str] | str | None expected_count: int | None # None = don't check exact count expect_error: bool error_match: str | None @@ -201,6 +201,41 @@ class ApplyFiltersFixture(t.NamedTuple): expect_error=False, error_match=None, ), + ApplyFiltersFixture( + test_id="string_filter_exact", + filters='{"session_name": ""}', + expected_count=1, + expect_error=False, + error_match=None, + ), + ApplyFiltersFixture( + test_id="string_filter_contains", + filters='{"session_name__contains": ""}', + expected_count=1, + expect_error=False, + error_match=None, + ), + ApplyFiltersFixture( + test_id="string_filter_invalid_json", + filters="{bad json", + expected_count=None, + expect_error=True, + error_match="Invalid filters JSON", + ), + ApplyFiltersFixture( + test_id="string_filter_not_object", + filters='"just a string"', + expected_count=None, + expect_error=True, + error_match="filters must be a JSON object", + ), + ApplyFiltersFixture( + test_id="string_filter_array", + filters='["not", "a", "dict"]', + expected_count=None, + expect_error=True, + error_match="filters must be a JSON object", + ), ] @@ -213,14 +248,19 @@ def test_apply_filters( mcp_server: Server, mcp_session: Session, test_id: str, - filters: dict[str, str] | None, + filters: dict[str, str] | str | None, expected_count: int | None, expect_error: bool, error_match: str | None, ) -> None: """_apply_filters bridges dict params to QueryList.filter().""" # Substitute placeholders with real session name - if filters is not None: + if isinstance(filters, str): + session_name = mcp_session.session_name + assert session_name is not None + filters = filters.replace("", session_name) + filters = filters.replace("", session_name[:4]) + elif filters is not None: session_name = mcp_session.session_name assert session_name is not None resolved: dict[str, str] = {} From 215c8b406fdec2045f3022b98d6ec1c4bcf8627c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 06:40:34 -0500 Subject: [PATCH 16/47] mcp(fix[pane_tools]): Query tmux for zoom state instead of missing attr why: `getattr(window, "window_zoomed_flag", "0")` always returned the default `"0"` because `window_zoomed_flag` is not a field on libtmux's `Window` object. This caused `zoom=True` on an already-zoomed pane to toggle it OFF (since `pane.resize(zoom=True)` is a toggle), and `zoom=False` on a zoomed pane to be a no-op. what: - Query zoom state via `window.cmd("display-message", "-p", "#{window_zoomed_flag}")` - Preserve idempotent semantics: zoom=True ensures zoomed, zoom=False ensures unzoomed --- src/libtmux/mcp/tools/pane_tools.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index bc1700dc7..84a6bad94 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -160,7 +160,8 @@ def resize_pane( ) if zoom is not None: window = pane.window - is_zoomed = getattr(window, "window_zoomed_flag", "0") == "1" + result = window.cmd("display-message", "-p", "#{window_zoomed_flag}") + is_zoomed = bool(result.stdout) and result.stdout[0] == "1" if zoom and not is_zoomed: pane.resize(zoom=True) elif not zoom and is_zoomed: From 7dfedbe4ad0b21902632ee078443824bad294610 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 06:41:04 -0500 Subject: [PATCH 17/47] mcp(fix[window_tools]): Split from specified pane, not active pane why: When `pane_id` was provided to `split_window`, the code resolved the pane but then called `window.split()`, which delegates to `self.active_pane.split()`. If the specified pane was not the active pane, the wrong pane got split. what: - Call `pane.split()` directly when `pane_id` is provided - Move direction validation before the pane/window branch - Keep `window.split()` path for window-level targeting --- src/libtmux/mcp/tools/window_tools.py | 38 +++++++++++++++------------ 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/libtmux/mcp/tools/window_tools.py b/src/libtmux/mcp/tools/window_tools.py index 821bb3541..0d7c646a4 100644 --- a/src/libtmux/mcp/tools/window_tools.py +++ b/src/libtmux/mcp/tools/window_tools.py @@ -125,17 +125,6 @@ def split_window( """ server = _get_server(socket_name=socket_name) - if pane_id is not None: - pane = _resolve_pane(server, pane_id=pane_id) - window = pane.window - else: - window = _resolve_window( - server, - window_id=window_id, - window_index=window_index, - session_name=session_name, - ) - pane_dir: PaneDirection | None = None if direction is not None: pane_dir = _DIRECTION_MAP.get(direction.lower()) @@ -146,12 +135,27 @@ def split_window( msg = f"Invalid direction: {direction!r}. Valid: {valid}" raise ToolError(msg) - new_pane = window.split( - direction=pane_dir, - size=size, - start_directory=start_directory, - shell=shell, - ) + if pane_id is not None: + pane = _resolve_pane(server, pane_id=pane_id) + new_pane = pane.split( + direction=pane_dir, + size=size, + start_directory=start_directory, + shell=shell, + ) + else: + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + ) + new_pane = window.split( + direction=pane_dir, + size=size, + start_directory=start_directory, + shell=shell, + ) return json.dumps(_serialize_pane(new_pane)) From 462db9c466ccdc9426d99e08ba065dd0bdf62444 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 06:41:38 -0500 Subject: [PATCH 18/47] mcp(fix[option_tools]): Reject target without scope instead of ignoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: When `target` was provided without `scope`, `_resolve_option_target` silently ignored the target and returned the server object. This caused `show_option(option="x", target="my_session")` to query the server instead of the intended session — a fail-open behavior. what: - Raise ToolError when target is provided but scope is None - Add test for target-without-scope error path --- src/libtmux/mcp/tools/option_tools.py | 6 ++++++ tests/mcp/test_option_tools.py | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/libtmux/mcp/tools/option_tools.py b/src/libtmux/mcp/tools/option_tools.py index de0eda185..f88afe9d8 100644 --- a/src/libtmux/mcp/tools/option_tools.py +++ b/src/libtmux/mcp/tools/option_tools.py @@ -43,6 +43,12 @@ def _resolve_option_target( msg = f"Invalid scope: {scope!r}. Valid: {valid}" raise ToolError(msg) + if target is not None and opt_scope is None: + from fastmcp.exceptions import ToolError + + msg = "scope is required when target is specified" + raise ToolError(msg) + if target is not None and opt_scope is not None: if opt_scope == OptionScope.Session: return _resolve_session(server, session_name=target), opt_scope diff --git a/tests/mcp/test_option_tools.py b/tests/mcp/test_option_tools.py index 66b793d5b..ef7ebe634 100644 --- a/tests/mcp/test_option_tools.py +++ b/tests/mcp/test_option_tools.py @@ -38,6 +38,18 @@ def test_show_option_invalid_scope(mcp_server: Server, mcp_session: Session) -> ) +def test_show_option_target_without_scope( + mcp_server: Server, mcp_session: Session +) -> None: + """show_option raises ToolError when target is given without scope.""" + with pytest.raises(ToolError, match="scope is required"): + show_option( + option="base-index", + target="some_session", + socket_name=mcp_server.socket_name, + ) + + def test_set_option(mcp_server: Server, mcp_session: Session) -> None: """set_option sets a tmux option.""" result = set_option( From 1c1a6fa55871d18430cceddad2fe57d860752ba2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 06:42:00 -0500 Subject: [PATCH 19/47] mcp(fix[pane_tools]): Use tmux-level reset instead of sending keystrokes why: `clear_pane` called `pane.clear()` which sends the literal text "reset" + Enter as keystrokes to the pane's foreground process. For non-shell panes (vim, REPL, TUI), this injects unexpected input. The tool's contract says "clear the pane" but the annotation says `destructiveHint: False`, compounding the mismatch. what: - Use `pane.reset()` which does tmux-level `send-keys -R \; clear-history` - This resets the terminal state and clears history without injecting keystrokes --- src/libtmux/mcp/tools/pane_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index 84a6bad94..679d01dea 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -314,7 +314,7 @@ def clear_pane( session_name=session_name, window_id=window_id, ) - pane.clear() + pane.reset() return f"Pane cleared: {pane.pane_id}" From b793fec233a5c1a0bcd59dbc9578d56e12ce1d8a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 06:42:33 -0500 Subject: [PATCH 20/47] mcp(fix[docs]): Use %1 not %%1 for pane ID examples in docstrings why: `%%1` in plain Python strings is literally two percent signs, not an escaped `%1`. These docstrings become MCP tool descriptions shown to AI agents, which would send `%%1` as the pane_id and fail lookups since tmux pane IDs use a single `%` prefix (`%0`, `%1`, etc.). what: - Replace all 11 instances of `%%1` with `%1` across server.py, _utils.py, pane_tools.py, and hierarchy.py --- src/libtmux/mcp/_utils.py | 2 +- src/libtmux/mcp/resources/hierarchy.py | 4 ++-- src/libtmux/mcp/server.py | 2 +- src/libtmux/mcp/tools/pane_tools.py | 14 +++++++------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/libtmux/mcp/_utils.py b/src/libtmux/mcp/_utils.py index b3bb3779e..140386756 100644 --- a/src/libtmux/mcp/_utils.py +++ b/src/libtmux/mcp/_utils.py @@ -228,7 +228,7 @@ def _resolve_pane( server : Server The tmux server. pane_id : str, optional - Pane ID (e.g. '%%1'). Globally unique within a server. + Pane ID (e.g. '%1'). Globally unique within a server. session_name : str, optional Session name for hierarchical resolution. session_id : str, optional diff --git a/src/libtmux/mcp/resources/hierarchy.py b/src/libtmux/mcp/resources/hierarchy.py index 527a1822a..76ca3081e 100644 --- a/src/libtmux/mcp/resources/hierarchy.py +++ b/src/libtmux/mcp/resources/hierarchy.py @@ -117,7 +117,7 @@ def get_pane(pane_id: str) -> str: Parameters ---------- pane_id : str - The pane ID (e.g. '%%1'). + The pane ID (e.g. '%1'). Returns ------- @@ -139,7 +139,7 @@ def get_pane_content(pane_id: str) -> str: Parameters ---------- pane_id : str - The pane ID (e.g. '%%1'). + The pane ID (e.g. '%1'). Returns ------- diff --git a/src/libtmux/mcp/server.py b/src/libtmux/mcp/server.py index 56bc78ca8..2d0909cc3 100644 --- a/src/libtmux/mcp/server.py +++ b/src/libtmux/mcp/server.py @@ -11,7 +11,7 @@ name="libtmux", instructions=( "libtmux MCP server for programmatic tmux control. " - "Use pane_id (e.g. '%%1') as the preferred targeting method - " + "Use pane_id (e.g. '%1') as the preferred targeting method - " "it is globally unique within a tmux server. " "Use send_keys to execute commands and capture_pane to read output. " "All tools accept an optional socket_name parameter for multi-server support." diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index 679d01dea..1095d0c0a 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -34,7 +34,7 @@ def send_keys( keys : str The keys or text to send. pane_id : str, optional - Pane ID (e.g. '%%1'). + Pane ID (e.g. '%1'). session_name : str, optional Session name for pane resolution. window_id : str, optional @@ -83,7 +83,7 @@ def capture_pane( Parameters ---------- pane_id : str, optional - Pane ID (e.g. '%%1'). + Pane ID (e.g. '%1'). session_name : str, optional Session name for pane resolution. window_id : str, optional @@ -126,7 +126,7 @@ def resize_pane( Parameters ---------- pane_id : str, optional - Pane ID (e.g. '%%1'). + Pane ID (e.g. '%1'). session_name : str, optional Session name for pane resolution. window_id : str, optional @@ -183,7 +183,7 @@ def kill_pane( Parameters ---------- pane_id : str, optional - Pane ID (e.g. '%%1'). + Pane ID (e.g. '%1'). session_name : str, optional Session name for pane resolution. window_id : str, optional @@ -223,7 +223,7 @@ def set_pane_title( title : str The new pane title. pane_id : str, optional - Pane ID (e.g. '%%1'). + Pane ID (e.g. '%1'). session_name : str, optional Session name for pane resolution. window_id : str, optional @@ -259,7 +259,7 @@ def get_pane_info( Parameters ---------- pane_id : str, optional - Pane ID (e.g. '%%1'). + Pane ID (e.g. '%1'). session_name : str, optional Session name for pane resolution. window_id : str, optional @@ -294,7 +294,7 @@ def clear_pane( Parameters ---------- pane_id : str, optional - Pane ID (e.g. '%%1'). + Pane ID (e.g. '%1'). session_name : str, optional Session name for pane resolution. window_id : str, optional From c8ae0438e12d01d7490171e77826cb757978bd92 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 06:43:16 -0500 Subject: [PATCH 21/47] mcp(fix[_utils]): Add thread safety to server cache and fix invalidation why: FastMCP runs sync tool functions in a thread pool via `anyio.to_thread.run_sync()`. The compound check-then-act pattern in `_get_server` (check `in`, access `[]`, possibly `del`) was not atomic, allowing concurrent tool calls to hit a `KeyError` when one thread deletes a dead server's cache entry between another thread's `in` check and `[]` access. Additionally, `_invalidate_server` did not resolve env vars (`LIBTMUX_SOCKET`, `LIBTMUX_SOCKET_PATH`), so calling `_invalidate_server(socket_name=None)` would search for `key[0] == None` but the cache key created by `_get_server` used the resolved env var value. what: - Add `threading.Lock` to protect `_server_cache` in both `_get_server` and `_invalidate_server` - Add env var resolution to `_invalidate_server` to match `_get_server` --- src/libtmux/mcp/_utils.py | 49 ++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/src/libtmux/mcp/_utils.py b/src/libtmux/mcp/_utils.py index 140386756..7b9a8e941 100644 --- a/src/libtmux/mcp/_utils.py +++ b/src/libtmux/mcp/_utils.py @@ -10,6 +10,7 @@ import json import logging import os +import threading import typing as t from libtmux import exc @@ -24,6 +25,7 @@ logger = logging.getLogger(__name__) _server_cache: dict[tuple[str | None, str | None, str | None], Server] = {} +_server_cache_lock = threading.Lock() def _get_server( @@ -52,22 +54,23 @@ def _get_server( tmux_bin = os.environ.get("LIBTMUX_TMUX_BIN") cache_key = (socket_name, socket_path, tmux_bin) - if cache_key in _server_cache: - cached = _server_cache[cache_key] - if not cached.is_alive(): - del _server_cache[cache_key] + with _server_cache_lock: + if cache_key in _server_cache: + cached = _server_cache[cache_key] + if not cached.is_alive(): + del _server_cache[cache_key] - if cache_key not in _server_cache: - kwargs: dict[str, t.Any] = {} - if socket_name is not None: - kwargs["socket_name"] = socket_name - if socket_path is not None: - kwargs["socket_path"] = socket_path - if tmux_bin is not None: - kwargs["tmux_bin"] = tmux_bin - _server_cache[cache_key] = Server(**kwargs) + if cache_key not in _server_cache: + kwargs: dict[str, t.Any] = {} + if socket_name is not None: + kwargs["socket_name"] = socket_name + if socket_path is not None: + kwargs["socket_path"] = socket_path + if tmux_bin is not None: + kwargs["tmux_bin"] = tmux_bin + _server_cache[cache_key] = Server(**kwargs) - return _server_cache[cache_key] + return _server_cache[cache_key] def _invalidate_server( @@ -83,11 +86,19 @@ def _invalidate_server( socket_path : str, optional tmux socket path used in the cache key. """ - keys_to_remove = [ - key for key in _server_cache if key[0] == socket_name and key[1] == socket_path - ] - for key in keys_to_remove: - del _server_cache[key] + if socket_name is None: + socket_name = os.environ.get("LIBTMUX_SOCKET") + if socket_path is None: + socket_path = os.environ.get("LIBTMUX_SOCKET_PATH") + + with _server_cache_lock: + keys_to_remove = [ + key + for key in _server_cache + if key[0] == socket_name and key[1] == socket_path + ] + for key in keys_to_remove: + del _server_cache[key] def _resolve_session( From 57828c8b61631ac7c7532249742558c6b35ed2f0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 06:43:42 -0500 Subject: [PATCH 22/47] mcp(fix[hierarchy]): Use ResourceError instead of ValueError why: Resource handlers raised `ValueError` for not-found conditions, while tool modules consistently use `ToolError` from fastmcp. FastMCP provides `ResourceError` specifically for resource operation errors. Using `ValueError` produces inconsistent error presentation to MCP clients since it's not a `FastMCPError` subclass. what: - Import and use `fastmcp.exceptions.ResourceError` for all not-found conditions in resource handlers --- src/libtmux/mcp/resources/hierarchy.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/libtmux/mcp/resources/hierarchy.py b/src/libtmux/mcp/resources/hierarchy.py index 76ca3081e..138f258b4 100644 --- a/src/libtmux/mcp/resources/hierarchy.py +++ b/src/libtmux/mcp/resources/hierarchy.py @@ -5,6 +5,8 @@ import json import typing as t +from fastmcp.exceptions import ResourceError + from libtmux.mcp._utils import ( _get_server, _serialize_pane, @@ -50,7 +52,7 @@ def get_session(session_name: str) -> str: session = server.sessions.get(session_name=session_name, default=None) if session is None: msg = f"Session not found: {session_name}" - raise ValueError(msg) + raise ResourceError(msg) result = _serialize_session(session) result["windows"] = [_serialize_window(w) for w in session.windows] @@ -74,7 +76,7 @@ def get_session_windows(session_name: str) -> str: session = server.sessions.get(session_name=session_name, default=None) if session is None: msg = f"Session not found: {session_name}" - raise ValueError(msg) + raise ResourceError(msg) windows = [_serialize_window(w) for w in session.windows] return json.dumps(windows, indent=2) @@ -99,12 +101,12 @@ def get_window(session_name: str, window_index: str) -> str: session = server.sessions.get(session_name=session_name, default=None) if session is None: msg = f"Session not found: {session_name}" - raise ValueError(msg) + raise ResourceError(msg) window = session.windows.get(window_index=window_index, default=None) if window is None: msg = f"Window not found: index {window_index}" - raise ValueError(msg) + raise ResourceError(msg) result = _serialize_window(window) result["panes"] = [_serialize_pane(p) for p in window.panes] @@ -128,7 +130,7 @@ def get_pane(pane_id: str) -> str: pane = server.panes.get(pane_id=pane_id, default=None) if pane is None: msg = f"Pane not found: {pane_id}" - raise ValueError(msg) + raise ResourceError(msg) return json.dumps(_serialize_pane(pane), indent=2) @@ -150,7 +152,7 @@ def get_pane_content(pane_id: str) -> str: pane = server.panes.get(pane_id=pane_id, default=None) if pane is None: msg = f"Pane not found: {pane_id}" - raise ValueError(msg) + raise ResourceError(msg) lines = pane.capture_pane() return "\n".join(lines) From bf056a5586e7ddc8eb15344f18dce5ffaeba9f85 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 06:44:08 -0500 Subject: [PATCH 23/47] mcp(fix[server_tools]): Avoid redundant is_alive() subprocess calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: `get_server_info` called `server.is_alive()` twice — once for the `is_alive` field and once to guard `len(server.sessions)`. Each call spawns a `tmux list-sessions` subprocess. what: - Store `is_alive()` result in local variable and reuse --- src/libtmux/mcp/tools/server_tools.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/libtmux/mcp/tools/server_tools.py b/src/libtmux/mcp/tools/server_tools.py index 08d46def7..71e189432 100644 --- a/src/libtmux/mcp/tools/server_tools.py +++ b/src/libtmux/mcp/tools/server_tools.py @@ -129,11 +129,12 @@ def get_server_info(socket_name: str | None = None) -> str: JSON object with server info. """ server = _get_server(socket_name=socket_name) + alive = server.is_alive() info: dict[str, t.Any] = { - "is_alive": server.is_alive(), + "is_alive": alive, "socket_name": server.socket_name, "socket_path": str(server.socket_path) if server.socket_path else None, - "session_count": len(server.sessions) if server.is_alive() else 0, + "session_count": len(server.sessions) if alive else 0, } try: result = server.cmd("display-message", "-p", "#{version}") From 13e2032e7a58c9f6bc7bf70d05f7f2efcbb8d8ee Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 06:44:36 -0500 Subject: [PATCH 24/47] mcp(fix): Guard entrypoint against missing fastmcp dependency why: The `libtmux-mcp` console script is always installed via `[project.scripts]`, but `fastmcp` is only declared in `[project.optional-dependencies]`. A plain `pip install libtmux` installs the CLI entrypoint, but invoking it crashes with `ModuleNotFoundError`. what: - Catch `ImportError` in `main()` and print a helpful install message directing users to `pip install libtmux[mcp]` --- src/libtmux/mcp/__init__.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/libtmux/mcp/__init__.py b/src/libtmux/mcp/__init__.py index a2bf2d914..f5edf73f5 100644 --- a/src/libtmux/mcp/__init__.py +++ b/src/libtmux/mcp/__init__.py @@ -5,6 +5,16 @@ def main() -> None: """Entry point for the libtmux MCP server.""" - from libtmux.mcp.server import run_server + try: + from libtmux.mcp.server import run_server + except ImportError: + import sys + + print( + "libtmux MCP server requires fastmcp. " + "Install with: pip install libtmux[mcp]", + file=sys.stderr, + ) + raise SystemExit(1) from None run_server() From 6088e7fae7f899083fdbbc96674c667d462adc34 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 07:20:52 -0500 Subject: [PATCH 25/47] mcp(feat[tools,resources]): Add complete MCP annotations and titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The MCP spec (2025-06-18) defines 4 tool annotation hints with defaults that can be misleading — `destructiveHint` defaults to `true` and `openWorldHint` defaults to `true`. Tools that only set `readOnlyHint: true` inherited the contradictory `destructiveHint: true` default. Since all tools interact with local tmux (not external APIs), `openWorldHint` should be `false` across the board. Additionally, the MCP spec supports `title` on tools and resources for human-readable display in MCP clients, but none were set. what: - Set all 4 annotations explicitly on all 25 tools (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) - Add human-readable `title` to all 25 tools and 6 resources - Set `openWorldHint: false` everywhere (local tmux, not external APIs) - Set `idempotentHint: true` on rename/set/resize/select/kill tools - Update MockMCP in test_resources.py to accept **kwargs --- src/libtmux/mcp/resources/hierarchy.py | 15 +++++---- src/libtmux/mcp/tools/env_tools.py | 18 +++++++++-- src/libtmux/mcp/tools/option_tools.py | 18 +++++++++-- src/libtmux/mcp/tools/pane_tools.py | 42 +++++++++++++++++++++----- src/libtmux/mcp/tools/server_tools.py | 32 ++++++++++++++++---- src/libtmux/mcp/tools/session_tools.py | 36 +++++++++++++++++++--- src/libtmux/mcp/tools/window_tools.py | 40 ++++++++++++++++++++---- tests/mcp/test_resources.py | 2 +- 8 files changed, 169 insertions(+), 34 deletions(-) diff --git a/src/libtmux/mcp/resources/hierarchy.py b/src/libtmux/mcp/resources/hierarchy.py index 138f258b4..406b7bd92 100644 --- a/src/libtmux/mcp/resources/hierarchy.py +++ b/src/libtmux/mcp/resources/hierarchy.py @@ -21,7 +21,7 @@ def register(mcp: FastMCP) -> None: """Register hierarchy resources with the FastMCP instance.""" - @mcp.resource("tmux://sessions") + @mcp.resource("tmux://sessions", title="All Sessions") def get_sessions() -> str: """List all tmux sessions. @@ -34,7 +34,7 @@ def get_sessions() -> str: sessions = [_serialize_session(s) for s in server.sessions] return json.dumps(sessions, indent=2) - @mcp.resource("tmux://sessions/{session_name}") + @mcp.resource("tmux://sessions/{session_name}", title="Session Detail") def get_session(session_name: str) -> str: """Get details of a specific tmux session. @@ -58,7 +58,7 @@ def get_session(session_name: str) -> str: result["windows"] = [_serialize_window(w) for w in session.windows] return json.dumps(result, indent=2) - @mcp.resource("tmux://sessions/{session_name}/windows") + @mcp.resource("tmux://sessions/{session_name}/windows", title="Session Windows") def get_session_windows(session_name: str) -> str: """List all windows in a tmux session. @@ -81,7 +81,10 @@ def get_session_windows(session_name: str) -> str: windows = [_serialize_window(w) for w in session.windows] return json.dumps(windows, indent=2) - @mcp.resource("tmux://sessions/{session_name}/windows/{window_index}") + @mcp.resource( + "tmux://sessions/{session_name}/windows/{window_index}", + title="Window Detail", + ) def get_window(session_name: str, window_index: str) -> str: """Get details of a specific window in a session. @@ -112,7 +115,7 @@ def get_window(session_name: str, window_index: str) -> str: result["panes"] = [_serialize_pane(p) for p in window.panes] return json.dumps(result, indent=2) - @mcp.resource("tmux://panes/{pane_id}") + @mcp.resource("tmux://panes/{pane_id}", title="Pane Detail") def get_pane(pane_id: str) -> str: """Get details of a specific pane. @@ -134,7 +137,7 @@ def get_pane(pane_id: str) -> str: return json.dumps(_serialize_pane(pane), indent=2) - @mcp.resource("tmux://panes/{pane_id}/content") + @mcp.resource("tmux://panes/{pane_id}/content", title="Pane Content") def get_pane_content(pane_id: str) -> str: """Capture and return the content of a pane. diff --git a/src/libtmux/mcp/tools/env_tools.py b/src/libtmux/mcp/tools/env_tools.py index 7d3c7ddc6..6d1aad8b9 100644 --- a/src/libtmux/mcp/tools/env_tools.py +++ b/src/libtmux/mcp/tools/env_tools.py @@ -97,5 +97,19 @@ def set_environment( def register(mcp: FastMCP) -> None: """Register environment tools with the MCP instance.""" - mcp.tool(annotations={"readOnlyHint": True})(show_environment) - mcp.tool(annotations={"destructiveHint": False})(set_environment) + _RO = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + } + mcp.tool(title="Show Environment", annotations=_RO)(show_environment) + mcp.tool( + title="Set Environment", + annotations={ + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, + )(set_environment) diff --git a/src/libtmux/mcp/tools/option_tools.py b/src/libtmux/mcp/tools/option_tools.py index f88afe9d8..6133c9507 100644 --- a/src/libtmux/mcp/tools/option_tools.py +++ b/src/libtmux/mcp/tools/option_tools.py @@ -130,5 +130,19 @@ def set_option( def register(mcp: FastMCP) -> None: """Register option tools with the MCP instance.""" - mcp.tool(annotations={"readOnlyHint": True})(show_option) - mcp.tool(annotations={"destructiveHint": False})(set_option) + _RO = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + } + mcp.tool(title="Show Option", annotations=_RO)(show_option) + mcp.tool( + title="Set Option", + annotations={ + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, + )(set_option) diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index 1095d0c0a..7f473d492 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -320,10 +320,38 @@ def clear_pane( def register(mcp: FastMCP) -> None: """Register pane-level tools with the MCP instance.""" - mcp.tool(annotations={"destructiveHint": True, "idempotentHint": False})(send_keys) - mcp.tool(annotations={"readOnlyHint": True})(capture_pane) - mcp.tool(annotations={"destructiveHint": False})(resize_pane) - mcp.tool(annotations={"destructiveHint": True})(kill_pane) - mcp.tool(annotations={"destructiveHint": False})(set_pane_title) - mcp.tool(annotations={"readOnlyHint": True})(get_pane_info) - mcp.tool(annotations={"destructiveHint": False})(clear_pane) + _RO = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + } + _IDEM = { + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + } + mcp.tool( + title="Send Keys", + annotations={ + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": False, + "openWorldHint": False, + }, + )(send_keys) + mcp.tool(title="Capture Pane", annotations=_RO)(capture_pane) + mcp.tool(title="Resize Pane", annotations=_IDEM)(resize_pane) + mcp.tool( + title="Kill Pane", + annotations={ + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": True, + "openWorldHint": False, + }, + )(kill_pane) + mcp.tool(title="Set Pane Title", annotations=_IDEM)(set_pane_title) + mcp.tool(title="Get Pane Info", annotations=_RO)(get_pane_info) + mcp.tool(title="Clear Pane", annotations=_IDEM)(clear_pane) diff --git a/src/libtmux/mcp/tools/server_tools.py b/src/libtmux/mcp/tools/server_tools.py index 71e189432..801677b99 100644 --- a/src/libtmux/mcp/tools/server_tools.py +++ b/src/libtmux/mcp/tools/server_tools.py @@ -146,9 +146,29 @@ def get_server_info(socket_name: str | None = None) -> str: def register(mcp: FastMCP) -> None: """Register server-level tools with the MCP instance.""" - mcp.tool(annotations={"readOnlyHint": True})(list_sessions) - mcp.tool(annotations={"destructiveHint": False, "idempotentHint": False})( - create_session - ) - mcp.tool(annotations={"destructiveHint": True})(kill_server) - mcp.tool(annotations={"readOnlyHint": True})(get_server_info) + _RO = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + } + mcp.tool(title="List Sessions", annotations=_RO)(list_sessions) + mcp.tool( + title="Create Session", + annotations={ + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": False, + }, + )(create_session) + mcp.tool( + title="Kill Server", + annotations={ + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": True, + "openWorldHint": False, + }, + )(kill_server) + mcp.tool(title="Get Server Info", annotations=_RO)(get_server_info) diff --git a/src/libtmux/mcp/tools/session_tools.py b/src/libtmux/mcp/tools/session_tools.py index 3c99665df..09bcce6fb 100644 --- a/src/libtmux/mcp/tools/session_tools.py +++ b/src/libtmux/mcp/tools/session_tools.py @@ -178,7 +178,35 @@ def kill_session( def register(mcp: FastMCP) -> None: """Register session-level tools with the MCP instance.""" - mcp.tool(annotations={"readOnlyHint": True})(list_windows) - mcp.tool(annotations={"destructiveHint": False})(create_window) - mcp.tool(annotations={"destructiveHint": False})(rename_session) - mcp.tool(annotations={"destructiveHint": True})(kill_session) + _RO = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + } + _IDEM = { + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + } + mcp.tool(title="List Windows", annotations=_RO)(list_windows) + mcp.tool( + title="Create Window", + annotations={ + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": False, + }, + )(create_window) + mcp.tool(title="Rename Session", annotations=_IDEM)(rename_session) + mcp.tool( + title="Kill Session", + annotations={ + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": True, + "openWorldHint": False, + }, + )(kill_session) diff --git a/src/libtmux/mcp/tools/window_tools.py b/src/libtmux/mcp/tools/window_tools.py index 0d7c646a4..2a3099108 100644 --- a/src/libtmux/mcp/tools/window_tools.py +++ b/src/libtmux/mcp/tools/window_tools.py @@ -334,9 +334,37 @@ def resize_window( def register(mcp: FastMCP) -> None: """Register window-level tools with the MCP instance.""" - mcp.tool(annotations={"readOnlyHint": True})(list_panes) - mcp.tool(annotations={"destructiveHint": False})(split_window) - mcp.tool(annotations={"destructiveHint": False})(rename_window) - mcp.tool(annotations={"destructiveHint": True})(kill_window) - mcp.tool(annotations={"destructiveHint": False})(select_layout) - mcp.tool(annotations={"destructiveHint": False})(resize_window) + _RO = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + } + _IDEM = { + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + } + mcp.tool(title="List Panes", annotations=_RO)(list_panes) + mcp.tool( + title="Split Window", + annotations={ + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": False, + }, + )(split_window) + mcp.tool(title="Rename Window", annotations=_IDEM)(rename_window) + mcp.tool( + title="Kill Window", + annotations={ + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": True, + "openWorldHint": False, + }, + )(kill_window) + mcp.tool(title="Select Layout", annotations=_IDEM)(select_layout) + mcp.tool(title="Resize Window", annotations=_IDEM)(resize_window) diff --git a/tests/mcp/test_resources.py b/tests/mcp/test_resources.py index 7485f9273..23d573507 100644 --- a/tests/mcp/test_resources.py +++ b/tests/mcp/test_resources.py @@ -26,7 +26,7 @@ def resource_functions(mcp_server: Server) -> dict[str, t.Any]: functions: dict[str, t.Any] = {} class MockMCP: - def resource(self, uri: str) -> t.Any: + def resource(self, uri: str, **kwargs: t.Any) -> t.Any: def decorator(fn: t.Any) -> t.Any: functions[uri] = fn return fn From d08261d890347bd0a1b425d0a2dddf969d2f69e2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 07:21:00 -0500 Subject: [PATCH 26/47] mcp(feat[server]): Add version and expand instructions why: The MCP lifecycle spec shows `serverInfo` with `name`, `title`, and `version` fields. The server was missing `version`. The instructions string also lacked the tmux hierarchy model and env var configuration that help LLMs use tools effectively. what: - Add `version` from `libtmux.__about__.__version__` - Add tmux hierarchy description (Server > Session > Window > Pane) - Document LIBTMUX_SOCKET env var default for socket_name --- src/libtmux/mcp/server.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/libtmux/mcp/server.py b/src/libtmux/mcp/server.py index 2d0909cc3..c77f46691 100644 --- a/src/libtmux/mcp/server.py +++ b/src/libtmux/mcp/server.py @@ -7,14 +7,19 @@ from fastmcp import FastMCP +from libtmux.__about__ import __version__ + mcp = FastMCP( name="libtmux", + version=__version__, instructions=( "libtmux MCP server for programmatic tmux control. " + "tmux hierarchy: Server > Session > Window > Pane. " "Use pane_id (e.g. '%1') as the preferred targeting method - " "it is globally unique within a tmux server. " "Use send_keys to execute commands and capture_pane to read output. " - "All tools accept an optional socket_name parameter for multi-server support." + "All tools accept an optional socket_name parameter for multi-server " + "support (defaults to LIBTMUX_SOCKET env var)." ), ) From 246cbfdacc919fc075f882e214c097d14fb76ec0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 07:51:21 -0500 Subject: [PATCH 27/47] py(deps[mcp]): Pin fastmcp to >=3.1.0,<4.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The previous pin `>=2.3.0` was too loose — FastMCP 3.x has a different API from 2.x (e.g. `title=` kwarg on `mcp.tool()`, `version=` on constructor). A future 4.x release could also break. The server uses 3.x features added in 3.1.0. what: - Pin fastmcp to `>=3.1.0,<4.0.0` - Update uv.lock --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9e30e7784..efe6b16f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,7 +114,7 @@ lint = [ [project.optional-dependencies] mcp = [ - "fastmcp>=2.3.0; python_version >= '3.10'", + "fastmcp>=3.1.0,<4.0.0; python_version >= '3.10'", ] [project.scripts] diff --git a/uv.lock b/uv.lock index da453bc51..a136e5aec 100644 --- a/uv.lock +++ b/uv.lock @@ -1082,7 +1082,7 @@ testing = [ ] [package.metadata] -requires-dist = [{ name = "fastmcp", marker = "python_full_version >= '3.10' and extra == 'mcp'", specifier = ">=2.3.0" }] +requires-dist = [{ name = "fastmcp", marker = "python_full_version >= '3.10' and extra == 'mcp'", specifier = ">=3.1.0,<4.0.0" }] provides-extras = ["mcp"] [package.metadata.requires-dev] From fc4f7d7dad07c53e8f7851bf9b6c6b09a4c8903c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 07:55:08 -0500 Subject: [PATCH 28/47] mcp(feat[pane_tools,window_tools]): Add session_id to pane tools and split_window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: All 7 pane tools and `split_window` accepted `session_name` but not `session_id`, while session/window-level tools consistently accept both. The `_resolve_pane()` and `_resolve_window()` helpers already support `session_id` — it just wasn't exposed in the tool signatures. what: - Add `session_id: str | None = None` parameter to send_keys, capture_pane, resize_pane, kill_pane, set_pane_title, get_pane_info, clear_pane, and split_window - Pass through to _resolve_pane()/_resolve_window() - Improve capture_pane start/end and split_window size descriptions (verified against tmux C source: cmd-capture-pane.c, cmd-split-window.c) - Clarify suppress_history as libtmux abstraction (space prefix) --- src/libtmux/mcp/tools/pane_tools.py | 34 +++++++++++++++++++++++++-- src/libtmux/mcp/tools/window_tools.py | 14 ++++++++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index 7f473d492..6661c07a7 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -21,6 +21,7 @@ def send_keys( keys: str, pane_id: str | None = None, session_name: str | None = None, + session_id: str | None = None, window_id: str | None = None, enter: bool = True, literal: bool = False, @@ -37,6 +38,8 @@ def send_keys( Pane ID (e.g. '%1'). session_name : str, optional Session name for pane resolution. + session_id : str, optional + Session ID (e.g. '$1') for pane resolution. window_id : str, optional Window ID for pane resolution. enter : bool @@ -44,7 +47,8 @@ def send_keys( literal : bool Whether to send keys literally (no tmux interpretation). Default False. suppress_history : bool - Whether to suppress shell history. Default False. + Whether to suppress shell history by prepending a space. + Only works in shells that support HISTCONTROL. Default False. socket_name : str, optional tmux socket name. @@ -58,6 +62,7 @@ def send_keys( server, pane_id=pane_id, session_name=session_name, + session_id=session_id, window_id=window_id, ) pane.send_keys( @@ -73,6 +78,7 @@ def send_keys( def capture_pane( pane_id: str | None = None, session_name: str | None = None, + session_id: str | None = None, window_id: str | None = None, start: int | None = None, end: int | None = None, @@ -86,10 +92,13 @@ def capture_pane( Pane ID (e.g. '%1'). session_name : str, optional Session name for pane resolution. + session_id : str, optional + Session ID (e.g. '$1') for pane resolution. window_id : str, optional Window ID for pane resolution. start : int, optional - Start line number (negative for scrollback history). + Start line number. 0 is the first visible line. Negative values + reach into scrollback history (e.g. -100 for last 100 lines). end : int, optional End line number. socket_name : str, optional @@ -105,6 +114,7 @@ def capture_pane( server, pane_id=pane_id, session_name=session_name, + session_id=session_id, window_id=window_id, ) lines = pane.capture_pane(start=start, end=end) @@ -115,6 +125,7 @@ def capture_pane( def resize_pane( pane_id: str | None = None, session_name: str | None = None, + session_id: str | None = None, window_id: str | None = None, height: int | None = None, width: int | None = None, @@ -129,6 +140,8 @@ def resize_pane( Pane ID (e.g. '%1'). session_name : str, optional Session name for pane resolution. + session_id : str, optional + Session ID (e.g. '$1') for pane resolution. window_id : str, optional Window ID for pane resolution. height : int, optional @@ -156,6 +169,7 @@ def resize_pane( server, pane_id=pane_id, session_name=session_name, + session_id=session_id, window_id=window_id, ) if zoom is not None: @@ -175,6 +189,7 @@ def resize_pane( def kill_pane( pane_id: str | None = None, session_name: str | None = None, + session_id: str | None = None, window_id: str | None = None, socket_name: str | None = None, ) -> str: @@ -186,6 +201,8 @@ def kill_pane( Pane ID (e.g. '%1'). session_name : str, optional Session name for pane resolution. + session_id : str, optional + Session ID (e.g. '$1') for pane resolution. window_id : str, optional Window ID for pane resolution. socket_name : str, optional @@ -201,6 +218,7 @@ def kill_pane( server, pane_id=pane_id, session_name=session_name, + session_id=session_id, window_id=window_id, ) pid = pane.pane_id @@ -213,6 +231,7 @@ def set_pane_title( title: str, pane_id: str | None = None, session_name: str | None = None, + session_id: str | None = None, window_id: str | None = None, socket_name: str | None = None, ) -> str: @@ -226,6 +245,8 @@ def set_pane_title( Pane ID (e.g. '%1'). session_name : str, optional Session name for pane resolution. + session_id : str, optional + Session ID (e.g. '$1') for pane resolution. window_id : str, optional Window ID for pane resolution. socket_name : str, optional @@ -241,6 +262,7 @@ def set_pane_title( server, pane_id=pane_id, session_name=session_name, + session_id=session_id, window_id=window_id, ) pane.set_title(title) @@ -251,6 +273,7 @@ def set_pane_title( def get_pane_info( pane_id: str | None = None, session_name: str | None = None, + session_id: str | None = None, window_id: str | None = None, socket_name: str | None = None, ) -> str: @@ -262,6 +285,8 @@ def get_pane_info( Pane ID (e.g. '%1'). session_name : str, optional Session name for pane resolution. + session_id : str, optional + Session ID (e.g. '$1') for pane resolution. window_id : str, optional Window ID for pane resolution. socket_name : str, optional @@ -277,6 +302,7 @@ def get_pane_info( server, pane_id=pane_id, session_name=session_name, + session_id=session_id, window_id=window_id, ) return json.dumps(_serialize_pane(pane)) @@ -286,6 +312,7 @@ def get_pane_info( def clear_pane( pane_id: str | None = None, session_name: str | None = None, + session_id: str | None = None, window_id: str | None = None, socket_name: str | None = None, ) -> str: @@ -297,6 +324,8 @@ def clear_pane( Pane ID (e.g. '%1'). session_name : str, optional Session name for pane resolution. + session_id : str, optional + Session ID (e.g. '$1') for pane resolution. window_id : str, optional Window ID for pane resolution. socket_name : str, optional @@ -312,6 +341,7 @@ def clear_pane( server, pane_id=pane_id, session_name=session_name, + session_id=session_id, window_id=window_id, ) pane.reset() diff --git a/src/libtmux/mcp/tools/window_tools.py b/src/libtmux/mcp/tools/window_tools.py index 2a3099108..9e531a2cb 100644 --- a/src/libtmux/mcp/tools/window_tools.py +++ b/src/libtmux/mcp/tools/window_tools.py @@ -87,6 +87,7 @@ def list_panes( def split_window( pane_id: str | None = None, session_name: str | None = None, + session_id: str | None = None, window_id: str | None = None, window_index: str | None = None, direction: str | None = None, @@ -100,9 +101,11 @@ def split_window( Parameters ---------- pane_id : str, optional - Pane ID to split from. If given, the pane's window is used. + Pane ID to split from. If given, splits adjacent to this pane. session_name : str, optional Session name. + session_id : str, optional + Session ID (e.g. '$1'). window_id : str, optional Window ID (e.g. '@1'). window_index : str, optional @@ -110,7 +113,8 @@ def split_window( direction : str, optional Split direction: 'above', 'below', 'left', or 'right'. size : str or int, optional - Size of the new pane (percentage or line count). + Size of the new pane. Use a string with '%%' suffix for + percentage (e.g. '50%%') or an integer for lines/columns. start_directory : str, optional Working directory for the new pane. shell : str, optional @@ -149,6 +153,7 @@ def split_window( window_id=window_id, window_index=window_index, session_name=session_name, + session_id=session_id, ) new_pane = window.split( direction=pane_dir, @@ -257,7 +262,10 @@ def select_layout( Parameters ---------- layout : str - Layout name (e.g. 'even-horizontal', 'tiled') or custom layout string. + Layout name or custom layout string. Built-in layouts: + 'even-horizontal', 'even-vertical', 'main-horizontal', + 'main-horizontal-mirrored', 'main-vertical', + 'main-vertical-mirrored', 'tiled'. window_id : str, optional Window ID (e.g. '@1'). window_index : str, optional From cbb548f608e01340aa228cac29a4ec1b2fa3740f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 07:55:17 -0500 Subject: [PATCH 29/47] mcp(fix[docs]): Clarify parameter descriptions from tmux source why: Several MCP tool parameter descriptions were ambiguous when cross-referenced against the tmux C source code. LLMs using these tools need precise format guidance to construct correct arguments. Verified against: - layout-set.c:43-49 for built-in layout names - cmd-split-window.c for size format - option_tools target format per scope what: - select_layout: list all 7 built-in layout names from tmux source - option_tools target: document expected format per scope (session name, window ID '@1', pane ID '%1') --- src/libtmux/mcp/tools/option_tools.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/libtmux/mcp/tools/option_tools.py b/src/libtmux/mcp/tools/option_tools.py index 6133c9507..60ffc9bac 100644 --- a/src/libtmux/mcp/tools/option_tools.py +++ b/src/libtmux/mcp/tools/option_tools.py @@ -76,7 +76,9 @@ def show_option( scope : str, optional Option scope: "server", "session", "window", or "pane". target : str, optional - Target session, window, or pane identifier. + Target identifier. For session scope: session name + (e.g. 'mysession'). For window scope: window ID (e.g. '@1'). + For pane scope: pane ID (e.g. '%1'). Requires scope. global_ : bool Whether to query the global option. socket_name : str, optional @@ -112,7 +114,9 @@ def set_option( scope : str, optional Option scope: "server", "session", "window", or "pane". target : str, optional - Target session, window, or pane identifier. + Target identifier. For session scope: session name + (e.g. 'mysession'). For window scope: window ID (e.g. '@1'). + For pane scope: pane ID (e.g. '%1'). Requires scope. global_ : bool Whether to set the global option. socket_name : str, optional From 38f6a8e4c2c8f7c9129087de2282853d0925168f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 08:10:03 -0500 Subject: [PATCH 30/47] mcp(feat[tools]): Use Literal types for enum parameters in JSON schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Parameters like `direction` and `scope` were typed as `str | None`, so the MCP input schema showed `{"type": "string"}` — LLMs had to read descriptions to discover valid values. Pydantic generates `{"enum": ["above", "below", ...]}` from `Literal` types, putting valid values directly in the JSON schema where LLMs can see them. what: - Use `t.Literal["above", "below", "left", "right"]` for split direction - Use `t.Literal["before", "after"]` for window placement direction - Use `t.Literal["server", "session", "window", "pane"]` for option scope - Keep manual validation as safety net for direct callers (belt-and-suspenders) --- src/libtmux/mcp/tools/option_tools.py | 10 +++++----- src/libtmux/mcp/tools/session_tools.py | 6 +++--- src/libtmux/mcp/tools/window_tools.py | 6 +++--- tests/mcp/test_option_tools.py | 2 +- tests/mcp/test_session_tools.py | 2 +- tests/mcp/test_window_tools.py | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/libtmux/mcp/tools/option_tools.py b/src/libtmux/mcp/tools/option_tools.py index 60ffc9bac..7a7f1181c 100644 --- a/src/libtmux/mcp/tools/option_tools.py +++ b/src/libtmux/mcp/tools/option_tools.py @@ -29,7 +29,7 @@ def _resolve_option_target( socket_name: str | None, - scope: str | None, + scope: t.Literal["server", "session", "window", "pane"] | None, target: str | None, ) -> tuple[OptionsMixin, OptionScope | None]: """Resolve the target object and scope for option operations.""" @@ -62,7 +62,7 @@ def _resolve_option_target( @handle_tool_errors def show_option( option: str, - scope: str | None = None, + scope: t.Literal["server", "session", "window", "pane"] | None = None, target: str | None = None, global_: bool = False, socket_name: str | None = None, @@ -74,7 +74,7 @@ def show_option( option : str The tmux option name to query. scope : str, optional - Option scope: "server", "session", "window", or "pane". + Option scope. target : str, optional Target identifier. For session scope: session name (e.g. 'mysession'). For window scope: window ID (e.g. '@1'). @@ -98,7 +98,7 @@ def show_option( def set_option( option: str, value: str, - scope: str | None = None, + scope: t.Literal["server", "session", "window", "pane"] | None = None, target: str | None = None, global_: bool = False, socket_name: str | None = None, @@ -112,7 +112,7 @@ def set_option( value : str The value to set. scope : str, optional - Option scope: "server", "session", "window", or "pane". + Option scope. target : str, optional Target identifier. For session scope: session name (e.g. 'mysession'). For window scope: window ID (e.g. '@1'). diff --git a/src/libtmux/mcp/tools/session_tools.py b/src/libtmux/mcp/tools/session_tools.py index 09bcce6fb..0d0107a70 100644 --- a/src/libtmux/mcp/tools/session_tools.py +++ b/src/libtmux/mcp/tools/session_tools.py @@ -64,7 +64,7 @@ def create_window( window_name: str | None = None, start_directory: str | None = None, attach: bool = False, - direction: str | None = None, + direction: t.Literal["before", "after"] | None = None, socket_name: str | None = None, ) -> str: """Create a new window in a tmux session. @@ -82,7 +82,7 @@ def create_window( attach : bool, optional Whether to make the new window active. direction : str, optional - Window placement direction: "before" or "after". + Window placement direction. socket_name : str, optional tmux socket name. Defaults to LIBTMUX_SOCKET env var. @@ -104,7 +104,7 @@ def create_window( "before": WindowDirection.Before, "after": WindowDirection.After, } - resolved = direction_map.get(direction.lower()) + resolved = direction_map.get(direction) if resolved is None: from fastmcp.exceptions import ToolError diff --git a/src/libtmux/mcp/tools/window_tools.py b/src/libtmux/mcp/tools/window_tools.py index 9e531a2cb..21c766e5c 100644 --- a/src/libtmux/mcp/tools/window_tools.py +++ b/src/libtmux/mcp/tools/window_tools.py @@ -90,7 +90,7 @@ def split_window( session_id: str | None = None, window_id: str | None = None, window_index: str | None = None, - direction: str | None = None, + direction: t.Literal["above", "below", "left", "right"] | None = None, size: str | int | None = None, start_directory: str | None = None, shell: str | None = None, @@ -111,7 +111,7 @@ def split_window( window_index : str, optional Window index within the session. direction : str, optional - Split direction: 'above', 'below', 'left', or 'right'. + Split direction. size : str or int, optional Size of the new pane. Use a string with '%%' suffix for percentage (e.g. '50%%') or an integer for lines/columns. @@ -131,7 +131,7 @@ def split_window( pane_dir: PaneDirection | None = None if direction is not None: - pane_dir = _DIRECTION_MAP.get(direction.lower()) + pane_dir = _DIRECTION_MAP.get(direction) if pane_dir is None: from fastmcp.exceptions import ToolError diff --git a/tests/mcp/test_option_tools.py b/tests/mcp/test_option_tools.py index ef7ebe634..09c5a995a 100644 --- a/tests/mcp/test_option_tools.py +++ b/tests/mcp/test_option_tools.py @@ -33,7 +33,7 @@ def test_show_option_invalid_scope(mcp_server: Server, mcp_session: Session) -> with pytest.raises(ToolError, match="Invalid scope"): show_option( option="base-index", - scope="global", + scope="global", # type: ignore[arg-type] socket_name=mcp_server.socket_name, ) diff --git a/tests/mcp/test_session_tools.py b/tests/mcp/test_session_tools.py index 95f37a83f..9196f0d2b 100644 --- a/tests/mcp/test_session_tools.py +++ b/tests/mcp/test_session_tools.py @@ -61,7 +61,7 @@ def test_create_window_invalid_direction( create_window( session_name=mcp_session.session_name, window_name="bad_dir", - direction="sideways", + direction="sideways", # type: ignore[arg-type] socket_name=mcp_server.socket_name, ) diff --git a/tests/mcp/test_window_tools.py b/tests/mcp/test_window_tools.py index 69bb77641..a9f6fff05 100644 --- a/tests/mcp/test_window_tools.py +++ b/tests/mcp/test_window_tools.py @@ -68,7 +68,7 @@ def test_split_window_invalid_direction( with pytest.raises(ToolError, match="Invalid direction"): split_window( window_id=window.window_id, - direction="diagonal", + direction="diagonal", # type: ignore[arg-type] socket_name=mcp_server.socket_name, ) From 4175ac122e1cc206cf4c5395a01c2edb688da1f1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 10:50:29 -0500 Subject: [PATCH 31/47] mcp(feat[models]): Add Pydantic output models for MCP tools why: Tools returned manually-constructed JSON strings with no `outputSchema` in the MCP tool definitions. MCP clients couldn't validate or introspect results, and tests had to `json.loads()` every return value. what: - Add `models.py` with Pydantic BaseModel classes: SessionInfo, WindowInfo, PaneInfo, ServerInfo, OptionResult, OptionSetResult, EnvironmentSetResult - Each field has `Field(description=...)` for MCP schema documentation - FastMCP auto-generates `outputSchema` from these return types --- src/libtmux/mcp/models.py | 91 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/libtmux/mcp/models.py diff --git a/src/libtmux/mcp/models.py b/src/libtmux/mcp/models.py new file mode 100644 index 000000000..08ffbb676 --- /dev/null +++ b/src/libtmux/mcp/models.py @@ -0,0 +1,91 @@ +"""Pydantic models for MCP tool inputs and outputs.""" + +from __future__ import annotations + +import typing as t + +from pydantic import BaseModel, Field + + +class SessionInfo(BaseModel): + """Serialized tmux session.""" + + session_id: str = Field(description="Session ID (e.g. '$1')") + session_name: str | None = Field(default=None, description="Session name") + window_count: int = Field(description="Number of windows") + session_attached: str | None = Field( + default=None, description="Attached client count" + ) + session_created: str | None = Field(default=None, description="Creation timestamp") + + +class WindowInfo(BaseModel): + """Serialized tmux window.""" + + window_id: str = Field(description="Window ID (e.g. '@1')") + window_name: str | None = Field(default=None, description="Window name") + window_index: str | None = Field(default=None, description="Window index") + session_id: str | None = Field(default=None, description="Parent session ID") + session_name: str | None = Field(default=None, description="Parent session name") + pane_count: int = Field(description="Number of panes") + window_layout: str | None = Field(default=None, description="Layout string") + window_active: str | None = Field( + default=None, description="Active flag ('1' or '0')" + ) + window_width: str | None = Field(default=None, description="Width in columns") + window_height: str | None = Field(default=None, description="Height in rows") + + +class PaneInfo(BaseModel): + """Serialized tmux pane.""" + + pane_id: str = Field(description="Pane ID (e.g. '%1')") + pane_index: str | None = Field(default=None, description="Pane index") + pane_width: str | None = Field(default=None, description="Width in columns") + pane_height: str | None = Field(default=None, description="Height in rows") + pane_current_command: str | None = Field( + default=None, description="Running command" + ) + pane_current_path: str | None = Field( + default=None, description="Current working directory" + ) + pane_pid: str | None = Field(default=None, description="Process ID") + pane_title: str | None = Field(default=None, description="Pane title") + pane_active: str | None = Field( + default=None, description="Active flag ('1' or '0')" + ) + window_id: str | None = Field(default=None, description="Parent window ID") + session_id: str | None = Field(default=None, description="Parent session ID") + + +class ServerInfo(BaseModel): + """Serialized tmux server info.""" + + is_alive: bool = Field(description="Whether the server is running") + socket_name: str | None = Field(default=None, description="Socket name") + socket_path: str | None = Field(default=None, description="Socket path") + session_count: int = Field(description="Number of sessions") + version: str | None = Field(default=None, description="tmux version") + + +class OptionResult(BaseModel): + """Result of a show_option call.""" + + option: str = Field(description="Option name") + value: t.Any = Field(description="Option value") + + +class OptionSetResult(BaseModel): + """Result of a set_option call.""" + + option: str = Field(description="Option name") + value: str = Field(description="Value that was set") + status: str = Field(description="Operation status") + + +class EnvironmentSetResult(BaseModel): + """Result of a set_environment call.""" + + name: str = Field(description="Variable name") + value: str = Field(description="Value that was set") + status: str = Field(description="Operation status") From 2d629eb6deb2710352020cf38b99869ae66000a3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 10:50:41 -0500 Subject: [PATCH 32/47] mcp(refactor[tools,resources]): Return Pydantic models instead of JSON strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Returning typed Pydantic models instead of `json.dumps()` strings gives MCP clients auto-generated `outputSchema` for result validation, lets tests assert on model attributes directly (`result.session_name`) instead of `json.loads(result)["session_name"]`, and centralizes field documentation in model `Field(description=...)` definitions. what: - Update `_serialize_*` functions to return Pydantic models - Update `_apply_filters` with generic TypeVar for typed returns - Change tool return types: `str` → `SessionInfo`, `WindowInfo`, `PaneInfo`, `ServerInfo`, `OptionResult`, `OptionSetResult`, `EnvironmentSetResult` - Keep `str` returns for message-only tools (kill_*, send_keys, clear_pane) and text output (capture_pane, show_environment) - Update resources to use `.model_dump()` before `json.dumps()` - Update all tests to assert on model attributes directly --- src/libtmux/mcp/_utils.py | 109 ++++++++++++++----------- src/libtmux/mcp/resources/hierarchy.py | 14 ++-- src/libtmux/mcp/tools/env_tools.py | 9 +- src/libtmux/mcp/tools/option_tools.py | 18 ++-- src/libtmux/mcp/tools/pane_tools.py | 26 +++--- src/libtmux/mcp/tools/server_tools.py | 43 +++++----- src/libtmux/mcp/tools/session_tools.py | 26 +++--- src/libtmux/mcp/tools/window_tools.py | 42 +++++----- tests/mcp/test_env_tools.py | 5 +- tests/mcp/test_option_tools.py | 13 ++- tests/mcp/test_pane_tools.py | 17 ++-- tests/mcp/test_server_tools.py | 32 +++----- tests/mcp/test_session_tools.py | 24 ++---- tests/mcp/test_utils.py | 36 ++++---- tests/mcp/test_window_tools.py | 30 +++---- 15 files changed, 219 insertions(+), 225 deletions(-) diff --git a/src/libtmux/mcp/_utils.py b/src/libtmux/mcp/_utils.py index 7b9a8e941..9f4598d60 100644 --- a/src/libtmux/mcp/_utils.py +++ b/src/libtmux/mcp/_utils.py @@ -18,6 +18,7 @@ from libtmux.server import Server if t.TYPE_CHECKING: + from libtmux.mcp.models import PaneInfo, SessionInfo, WindowInfo from libtmux.pane import Pane from libtmux.session import Session from libtmux.window import Window @@ -286,11 +287,14 @@ def _resolve_pane( return panes[0] +M = t.TypeVar("M") + + def _apply_filters( items: t.Any, filters: dict[str, str] | str | None, - serializer: t.Callable[..., dict[str, t.Any]], -) -> list[dict[str, t.Any]]: + serializer: t.Callable[..., M], +) -> list[M]: """Apply QueryList filters and serialize results. Parameters @@ -302,11 +306,11 @@ def _apply_filters( or as a JSON string. Some MCP clients require the string form. If None or empty, all items are returned. serializer : callable - Serializer function to convert each item to a dict. + Serializer function to convert each item to a model. Returns ------- - list[dict] + list Serialized list of matching items. Raises @@ -349,8 +353,8 @@ def _apply_filters( return [serializer(item) for item in filtered] -def _serialize_session(session: Session) -> dict[str, t.Any]: - """Serialize a Session to a JSON-compatible dict. +def _serialize_session(session: Session) -> SessionInfo: + """Serialize a Session to a Pydantic model. Parameters ---------- @@ -359,20 +363,23 @@ def _serialize_session(session: Session) -> dict[str, t.Any]: Returns ------- - dict - Session data including id, name, window count, dimensions. + SessionInfo + Session data including id, name, window count. """ - return { - "session_id": session.session_id, - "session_name": session.session_name, - "window_count": len(session.windows), - "session_attached": getattr(session, "session_attached", None), - "session_created": getattr(session, "session_created", None), - } + from libtmux.mcp.models import SessionInfo + + assert session.session_id is not None + return SessionInfo( + session_id=session.session_id, + session_name=session.session_name, + window_count=len(session.windows), + session_attached=getattr(session, "session_attached", None), + session_created=getattr(session, "session_created", None), + ) -def _serialize_window(window: Window) -> dict[str, t.Any]: - """Serialize a Window to a JSON-compatible dict. +def _serialize_window(window: Window) -> WindowInfo: + """Serialize a Window to a Pydantic model. Parameters ---------- @@ -381,25 +388,28 @@ def _serialize_window(window: Window) -> dict[str, t.Any]: Returns ------- - dict + WindowInfo Window data including id, name, index, pane count, layout. """ - return { - "window_id": window.window_id, - "window_name": window.window_name, - "window_index": window.window_index, - "session_id": window.session_id, - "session_name": getattr(window, "session_name", None), - "pane_count": len(window.panes), - "window_layout": getattr(window, "window_layout", None), - "window_active": getattr(window, "window_active", None), - "window_width": getattr(window, "window_width", None), - "window_height": getattr(window, "window_height", None), - } - - -def _serialize_pane(pane: Pane) -> dict[str, t.Any]: - """Serialize a Pane to a JSON-compatible dict. + from libtmux.mcp.models import WindowInfo + + assert window.window_id is not None + return WindowInfo( + window_id=window.window_id, + window_name=window.window_name, + window_index=window.window_index, + session_id=window.session_id, + session_name=getattr(window, "session_name", None), + pane_count=len(window.panes), + window_layout=getattr(window, "window_layout", None), + window_active=getattr(window, "window_active", None), + window_width=getattr(window, "window_width", None), + window_height=getattr(window, "window_height", None), + ) + + +def _serialize_pane(pane: Pane) -> PaneInfo: + """Serialize a Pane to a Pydantic model. Parameters ---------- @@ -408,22 +418,25 @@ def _serialize_pane(pane: Pane) -> dict[str, t.Any]: Returns ------- - dict + PaneInfo Pane data including id, dimensions, current command, title. """ - return { - "pane_id": pane.pane_id, - "pane_index": getattr(pane, "pane_index", None), - "pane_width": getattr(pane, "pane_width", None), - "pane_height": getattr(pane, "pane_height", None), - "pane_current_command": getattr(pane, "pane_current_command", None), - "pane_current_path": getattr(pane, "pane_current_path", None), - "pane_pid": getattr(pane, "pane_pid", None), - "pane_title": getattr(pane, "pane_title", None), - "pane_active": getattr(pane, "pane_active", None), - "window_id": pane.window_id, - "session_id": pane.session_id, - } + from libtmux.mcp.models import PaneInfo + + assert pane.pane_id is not None + return PaneInfo( + pane_id=pane.pane_id, + pane_index=getattr(pane, "pane_index", None), + pane_width=getattr(pane, "pane_width", None), + pane_height=getattr(pane, "pane_height", None), + pane_current_command=getattr(pane, "pane_current_command", None), + pane_current_path=getattr(pane, "pane_current_path", None), + pane_pid=getattr(pane, "pane_pid", None), + pane_title=getattr(pane, "pane_title", None), + pane_active=getattr(pane, "pane_active", None), + window_id=pane.window_id, + session_id=pane.session_id, + ) P = t.ParamSpec("P") diff --git a/src/libtmux/mcp/resources/hierarchy.py b/src/libtmux/mcp/resources/hierarchy.py index 406b7bd92..cdddb19c7 100644 --- a/src/libtmux/mcp/resources/hierarchy.py +++ b/src/libtmux/mcp/resources/hierarchy.py @@ -31,7 +31,7 @@ def get_sessions() -> str: JSON array of session objects. """ server = _get_server() - sessions = [_serialize_session(s) for s in server.sessions] + sessions = [_serialize_session(s).model_dump() for s in server.sessions] return json.dumps(sessions, indent=2) @mcp.resource("tmux://sessions/{session_name}", title="Session Detail") @@ -54,8 +54,8 @@ def get_session(session_name: str) -> str: msg = f"Session not found: {session_name}" raise ResourceError(msg) - result = _serialize_session(session) - result["windows"] = [_serialize_window(w) for w in session.windows] + result = _serialize_session(session).model_dump() + result["windows"] = [_serialize_window(w).model_dump() for w in session.windows] return json.dumps(result, indent=2) @mcp.resource("tmux://sessions/{session_name}/windows", title="Session Windows") @@ -78,7 +78,7 @@ def get_session_windows(session_name: str) -> str: msg = f"Session not found: {session_name}" raise ResourceError(msg) - windows = [_serialize_window(w) for w in session.windows] + windows = [_serialize_window(w).model_dump() for w in session.windows] return json.dumps(windows, indent=2) @mcp.resource( @@ -111,8 +111,8 @@ def get_window(session_name: str, window_index: str) -> str: msg = f"Window not found: index {window_index}" raise ResourceError(msg) - result = _serialize_window(window) - result["panes"] = [_serialize_pane(p) for p in window.panes] + result = _serialize_window(window).model_dump() + result["panes"] = [_serialize_pane(p).model_dump() for p in window.panes] return json.dumps(result, indent=2) @mcp.resource("tmux://panes/{pane_id}", title="Pane Detail") @@ -135,7 +135,7 @@ def get_pane(pane_id: str) -> str: msg = f"Pane not found: {pane_id}" raise ResourceError(msg) - return json.dumps(_serialize_pane(pane), indent=2) + return json.dumps(_serialize_pane(pane).model_dump(), indent=2) @mcp.resource("tmux://panes/{pane_id}/content", title="Pane Content") def get_pane_content(pane_id: str) -> str: diff --git a/src/libtmux/mcp/tools/env_tools.py b/src/libtmux/mcp/tools/env_tools.py index 6d1aad8b9..12dfb270d 100644 --- a/src/libtmux/mcp/tools/env_tools.py +++ b/src/libtmux/mcp/tools/env_tools.py @@ -10,6 +10,7 @@ _resolve_session, handle_tool_errors, ) +from libtmux.mcp.models import EnvironmentSetResult if t.TYPE_CHECKING: from fastmcp import FastMCP @@ -59,7 +60,7 @@ def set_environment( session_name: str | None = None, session_id: str | None = None, socket_name: str | None = None, -) -> str: +) -> EnvironmentSetResult: """Set a tmux environment variable. Parameters @@ -77,8 +78,8 @@ def set_environment( Returns ------- - str - JSON confirming the variable was set. + EnvironmentSetResult + Confirmation with variable name, value, and status. """ server = _get_server(socket_name=socket_name) @@ -92,7 +93,7 @@ def set_environment( else: server.set_environment(name, value) - return json.dumps({"name": name, "value": value, "status": "set"}) + return EnvironmentSetResult(name=name, value=value, status="set") def register(mcp: FastMCP) -> None: diff --git a/src/libtmux/mcp/tools/option_tools.py b/src/libtmux/mcp/tools/option_tools.py index 7a7f1181c..7a6af1efe 100644 --- a/src/libtmux/mcp/tools/option_tools.py +++ b/src/libtmux/mcp/tools/option_tools.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import typing as t from libtmux.constants import OptionScope @@ -13,6 +12,7 @@ _resolve_window, handle_tool_errors, ) +from libtmux.mcp.models import OptionResult, OptionSetResult if t.TYPE_CHECKING: from fastmcp import FastMCP @@ -66,7 +66,7 @@ def show_option( target: str | None = None, global_: bool = False, socket_name: str | None = None, -) -> str: +) -> OptionResult: """Show a tmux option value. Parameters @@ -86,12 +86,12 @@ def show_option( Returns ------- - str - JSON with the option name and its value. + OptionResult + Option name and its value. """ obj, opt_scope = _resolve_option_target(socket_name, scope, target) value = obj.show_option(option, global_=global_, scope=opt_scope) - return json.dumps({"option": option, "value": value}) + return OptionResult(option=option, value=value) @handle_tool_errors @@ -102,7 +102,7 @@ def set_option( target: str | None = None, global_: bool = False, socket_name: str | None = None, -) -> str: +) -> OptionSetResult: """Set a tmux option value. Parameters @@ -124,12 +124,12 @@ def set_option( Returns ------- - str - JSON confirming the option was set. + OptionSetResult + Confirmation with option name, value, and status. """ obj, opt_scope = _resolve_option_target(socket_name, scope, target) obj.set_option(option, value, global_=global_, scope=opt_scope) - return json.dumps({"option": option, "value": value, "status": "set"}) + return OptionSetResult(option=option, value=value, status="set") def register(mcp: FastMCP) -> None: diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index 6661c07a7..2d2adaed2 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import typing as t from libtmux.mcp._utils import ( @@ -11,6 +10,7 @@ _serialize_pane, handle_tool_errors, ) +from libtmux.mcp.models import PaneInfo if t.TYPE_CHECKING: from fastmcp import FastMCP @@ -131,7 +131,7 @@ def resize_pane( width: int | None = None, zoom: bool | None = None, socket_name: str | None = None, -) -> str: +) -> PaneInfo: """Resize a tmux pane. Parameters @@ -155,8 +155,8 @@ def resize_pane( Returns ------- - str - JSON of the updated pane. + PaneInfo + Serialized pane object. """ from fastmcp.exceptions import ToolError @@ -182,7 +182,7 @@ def resize_pane( pane.resize(zoom=True) # toggle off else: pane.resize(height=height, width=width) - return json.dumps(_serialize_pane(pane)) + return _serialize_pane(pane) @handle_tool_errors @@ -234,7 +234,7 @@ def set_pane_title( session_id: str | None = None, window_id: str | None = None, socket_name: str | None = None, -) -> str: +) -> PaneInfo: """Set the title of a tmux pane. Parameters @@ -254,8 +254,8 @@ def set_pane_title( Returns ------- - str - JSON of the updated pane. + PaneInfo + Serialized pane object. """ server = _get_server(socket_name=socket_name) pane = _resolve_pane( @@ -266,7 +266,7 @@ def set_pane_title( window_id=window_id, ) pane.set_title(title) - return json.dumps(_serialize_pane(pane)) + return _serialize_pane(pane) @handle_tool_errors @@ -276,7 +276,7 @@ def get_pane_info( session_id: str | None = None, window_id: str | None = None, socket_name: str | None = None, -) -> str: +) -> PaneInfo: """Get detailed information about a tmux pane. Parameters @@ -294,8 +294,8 @@ def get_pane_info( Returns ------- - str - JSON of pane details. + PaneInfo + Serialized pane details. """ server = _get_server(socket_name=socket_name) pane = _resolve_pane( @@ -305,7 +305,7 @@ def get_pane_info( session_id=session_id, window_id=window_id, ) - return json.dumps(_serialize_pane(pane)) + return _serialize_pane(pane) @handle_tool_errors diff --git a/src/libtmux/mcp/tools/server_tools.py b/src/libtmux/mcp/tools/server_tools.py index 801677b99..1097dea3f 100644 --- a/src/libtmux/mcp/tools/server_tools.py +++ b/src/libtmux/mcp/tools/server_tools.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import typing as t from libtmux.mcp._utils import ( @@ -12,6 +11,7 @@ _serialize_session, handle_tool_errors, ) +from libtmux.mcp.models import ServerInfo, SessionInfo if t.TYPE_CHECKING: from fastmcp import FastMCP @@ -21,7 +21,7 @@ def list_sessions( socket_name: str | None = None, filters: dict[str, str] | str | None = None, -) -> str: +) -> list[SessionInfo]: """List all tmux sessions. Parameters @@ -34,12 +34,12 @@ def list_sessions( Returns ------- - str - JSON array of session objects. + list[SessionInfo] + List of session objects. """ server = _get_server(socket_name=socket_name) sessions = server.sessions - return json.dumps(_apply_filters(sessions, filters, _serialize_session)) + return _apply_filters(sessions, filters, _serialize_session) @handle_tool_errors @@ -51,7 +51,7 @@ def create_session( y: int | None = None, environment: dict[str, str] | None = None, socket_name: str | None = None, -) -> str: +) -> SessionInfo: """Create a new tmux session. Parameters @@ -73,8 +73,8 @@ def create_session( Returns ------- - str - JSON object of the created session. + SessionInfo + The created session. """ server = _get_server(socket_name=socket_name) kwargs: dict[str, t.Any] = {} @@ -91,7 +91,7 @@ def create_session( if environment is not None: kwargs["environment"] = environment session = server.new_session(**kwargs) - return json.dumps(_serialize_session(session)) + return _serialize_session(session) @handle_tool_errors @@ -115,7 +115,7 @@ def kill_server(socket_name: str | None = None) -> str: @handle_tool_errors -def get_server_info(socket_name: str | None = None) -> str: +def get_server_info(socket_name: str | None = None) -> ServerInfo: """Get information about the tmux server. Parameters @@ -125,23 +125,24 @@ def get_server_info(socket_name: str | None = None) -> str: Returns ------- - str - JSON object with server info. + ServerInfo + Server information. """ server = _get_server(socket_name=socket_name) alive = server.is_alive() - info: dict[str, t.Any] = { - "is_alive": alive, - "socket_name": server.socket_name, - "socket_path": str(server.socket_path) if server.socket_path else None, - "session_count": len(server.sessions) if alive else 0, - } + version: str | None = None try: result = server.cmd("display-message", "-p", "#{version}") - info["version"] = result.stdout[0] if result.stdout else None + version = result.stdout[0] if result.stdout else None except Exception: - info["version"] = None - return json.dumps(info) + pass + return ServerInfo( + is_alive=alive, + socket_name=server.socket_name, + socket_path=str(server.socket_path) if server.socket_path else None, + session_count=len(server.sessions) if alive else 0, + version=version, + ) def register(mcp: FastMCP) -> None: diff --git a/src/libtmux/mcp/tools/session_tools.py b/src/libtmux/mcp/tools/session_tools.py index 0d0107a70..6ef642179 100644 --- a/src/libtmux/mcp/tools/session_tools.py +++ b/src/libtmux/mcp/tools/session_tools.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import typing as t from libtmux.constants import WindowDirection @@ -14,6 +13,7 @@ _serialize_window, handle_tool_errors, ) +from libtmux.mcp.models import SessionInfo, WindowInfo if t.TYPE_CHECKING: from fastmcp import FastMCP @@ -25,7 +25,7 @@ def list_windows( session_id: str | None = None, socket_name: str | None = None, filters: dict[str, str] | str | None = None, -) -> str: +) -> list[WindowInfo]: """List windows in a tmux session, or all windows across sessions. Parameters @@ -43,8 +43,8 @@ def list_windows( Returns ------- - str - JSON array of window objects. + list[WindowInfo] + List of serialized window objects. """ server = _get_server(socket_name=socket_name) if session_name is not None or session_id is not None: @@ -54,7 +54,7 @@ def list_windows( windows = session.windows else: windows = server.windows - return json.dumps(_apply_filters(windows, filters, _serialize_window)) + return _apply_filters(windows, filters, _serialize_window) @handle_tool_errors @@ -66,7 +66,7 @@ def create_window( attach: bool = False, direction: t.Literal["before", "after"] | None = None, socket_name: str | None = None, -) -> str: +) -> WindowInfo: """Create a new window in a tmux session. Parameters @@ -88,8 +88,8 @@ def create_window( Returns ------- - str - JSON object of the created window. + WindowInfo + Serialized window object. """ server = _get_server(socket_name=socket_name) session = _resolve_session(server, session_name=session_name, session_id=session_id) @@ -113,7 +113,7 @@ def create_window( raise ToolError(msg) kwargs["direction"] = resolved window = session.new_window(**kwargs) - return json.dumps(_serialize_window(window)) + return _serialize_window(window) @handle_tool_errors @@ -122,7 +122,7 @@ def rename_session( session_name: str | None = None, session_id: str | None = None, socket_name: str | None = None, -) -> str: +) -> SessionInfo: """Rename a tmux session. Parameters @@ -138,13 +138,13 @@ def rename_session( Returns ------- - str - JSON object of the renamed session. + SessionInfo + Serialized session object. """ server = _get_server(socket_name=socket_name) session = _resolve_session(server, session_name=session_name, session_id=session_id) session = session.rename_session(new_name) - return json.dumps(_serialize_session(session)) + return _serialize_session(session) @handle_tool_errors diff --git a/src/libtmux/mcp/tools/window_tools.py b/src/libtmux/mcp/tools/window_tools.py index 21c766e5c..5f6d7162f 100644 --- a/src/libtmux/mcp/tools/window_tools.py +++ b/src/libtmux/mcp/tools/window_tools.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import typing as t from libtmux.constants import PaneDirection @@ -16,6 +15,7 @@ _serialize_window, handle_tool_errors, ) +from libtmux.mcp.models import PaneInfo, WindowInfo if t.TYPE_CHECKING: from fastmcp import FastMCP @@ -36,7 +36,7 @@ def list_panes( window_index: str | None = None, socket_name: str | None = None, filters: dict[str, str] | str | None = None, -) -> str: +) -> list[PaneInfo]: """List panes in a tmux window, session, or across the entire server. Parameters @@ -60,8 +60,8 @@ def list_panes( Returns ------- - str - JSON array of serialized pane objects. + list[PaneInfo] + List of serialized pane objects. """ server = _get_server(socket_name=socket_name) if window_id is not None or window_index is not None: @@ -80,7 +80,7 @@ def list_panes( panes = session.panes else: panes = server.panes - return json.dumps(_apply_filters(panes, filters, _serialize_pane)) + return _apply_filters(panes, filters, _serialize_pane) @handle_tool_errors @@ -95,7 +95,7 @@ def split_window( start_directory: str | None = None, shell: str | None = None, socket_name: str | None = None, -) -> str: +) -> PaneInfo: """Split a tmux window to create a new pane. Parameters @@ -124,8 +124,8 @@ def split_window( Returns ------- - str - JSON of the newly created pane. + PaneInfo + Serialized pane object. """ server = _get_server(socket_name=socket_name) @@ -161,7 +161,7 @@ def split_window( start_directory=start_directory, shell=shell, ) - return json.dumps(_serialize_pane(new_pane)) + return _serialize_pane(new_pane) @handle_tool_errors @@ -172,7 +172,7 @@ def rename_window( session_name: str | None = None, session_id: str | None = None, socket_name: str | None = None, -) -> str: +) -> WindowInfo: """Rename a tmux window. Parameters @@ -192,8 +192,8 @@ def rename_window( Returns ------- - str - JSON of the updated window. + WindowInfo + Serialized window object. """ server = _get_server(socket_name=socket_name) window = _resolve_window( @@ -204,7 +204,7 @@ def rename_window( session_id=session_id, ) window.rename_window(new_name) - return json.dumps(_serialize_window(window)) + return _serialize_window(window) @handle_tool_errors @@ -256,7 +256,7 @@ def select_layout( session_name: str | None = None, session_id: str | None = None, socket_name: str | None = None, -) -> str: +) -> WindowInfo: """Set the layout of a tmux window. Parameters @@ -279,8 +279,8 @@ def select_layout( Returns ------- - str - JSON of the updated window. + WindowInfo + Serialized window object. """ server = _get_server(socket_name=socket_name) window = _resolve_window( @@ -291,7 +291,7 @@ def select_layout( session_id=session_id, ) window.select_layout(layout) - return json.dumps(_serialize_window(window)) + return _serialize_window(window) @handle_tool_errors @@ -303,7 +303,7 @@ def resize_window( height: int | None = None, width: int | None = None, socket_name: str | None = None, -) -> str: +) -> WindowInfo: """Resize a tmux window. Parameters @@ -325,8 +325,8 @@ def resize_window( Returns ------- - str - JSON of the updated window. + WindowInfo + Serialized window object. """ server = _get_server(socket_name=socket_name) window = _resolve_window( @@ -337,7 +337,7 @@ def resize_window( session_id=session_id, ) window.resize(height=height, width=width) - return json.dumps(_serialize_window(window)) + return _serialize_window(window) def register(mcp: FastMCP) -> None: diff --git a/tests/mcp/test_env_tools.py b/tests/mcp/test_env_tools.py index ee00cbd15..a481d8599 100644 --- a/tests/mcp/test_env_tools.py +++ b/tests/mcp/test_env_tools.py @@ -26,9 +26,8 @@ def test_set_environment(mcp_server: Server, mcp_session: Session) -> None: value="test_value", socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert data["status"] == "set" - assert data["name"] == "MCP_TEST_VAR" + assert result.status == "set" + assert result.name == "MCP_TEST_VAR" def test_set_and_show_environment(mcp_server: Server, mcp_session: Session) -> None: diff --git a/tests/mcp/test_option_tools.py b/tests/mcp/test_option_tools.py index 09c5a995a..ae22b8028 100644 --- a/tests/mcp/test_option_tools.py +++ b/tests/mcp/test_option_tools.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import typing as t import pytest @@ -16,16 +15,15 @@ def test_show_option(mcp_server: Server, mcp_session: Session) -> None: - """show_option returns an option value.""" + """show_option returns an OptionResult model.""" result = show_option( option="base-index", scope="session", global_=True, socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert data["option"] == "base-index" - assert "value" in data + assert result.option == "base-index" + assert result.value is not None def test_show_option_invalid_scope(mcp_server: Server, mcp_session: Session) -> None: @@ -59,6 +57,5 @@ def test_set_option(mcp_server: Server, mcp_session: Session) -> None: global_=True, socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert data["status"] == "set" - assert data["option"] == "display-time" + assert result.status == "set" + assert result.option == "display-time" diff --git a/tests/mcp/test_pane_tools.py b/tests/mcp/test_pane_tools.py index 175bc01a2..318da142f 100644 --- a/tests/mcp/test_pane_tools.py +++ b/tests/mcp/test_pane_tools.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import typing as t import pytest @@ -49,10 +48,9 @@ def test_get_pane_info(mcp_server: Server, mcp_pane: Pane) -> None: pane_id=mcp_pane.pane_id, socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert data["pane_id"] == mcp_pane.pane_id - assert "pane_width" in data - assert "pane_height" in data + assert result.pane_id == mcp_pane.pane_id + assert result.pane_width is not None + assert result.pane_height is not None def test_set_pane_title(mcp_server: Server, mcp_pane: Pane) -> None: @@ -62,8 +60,7 @@ def test_set_pane_title(mcp_server: Server, mcp_pane: Pane) -> None: pane_id=mcp_pane.pane_id, socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert data["pane_id"] == mcp_pane.pane_id + assert result.pane_id == mcp_pane.pane_id def test_clear_pane(mcp_server: Server, mcp_pane: Pane) -> None: @@ -83,8 +80,7 @@ def test_resize_pane_dimensions(mcp_server: Server, mcp_pane: Pane) -> None: width=40, socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert data["pane_id"] == mcp_pane.pane_id + assert result.pane_id == mcp_pane.pane_id def test_resize_pane_zoom(mcp_server: Server, mcp_session: Session) -> None: @@ -98,8 +94,7 @@ def test_resize_pane_zoom(mcp_server: Server, mcp_session: Session) -> None: zoom=True, socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert data["pane_id"] == pane.pane_id + assert result.pane_id == pane.pane_id def test_resize_pane_zoom_mutual_exclusivity( diff --git a/tests/mcp/test_server_tools.py b/tests/mcp/test_server_tools.py index 5ecd541ad..12a4d8c0b 100644 --- a/tests/mcp/test_server_tools.py +++ b/tests/mcp/test_server_tools.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import typing as t import pytest @@ -20,23 +19,21 @@ def test_list_sessions(mcp_server: Server, mcp_session: Session) -> None: - """list_sessions returns JSON array of sessions.""" + """list_sessions returns a list of SessionInfo models.""" result = list_sessions(socket_name=mcp_server.socket_name) - data = json.loads(result) - assert isinstance(data, list) - assert len(data) >= 1 - session_ids = [s["session_id"] for s in data] + assert isinstance(result, list) + assert len(result) >= 1 + session_ids = [s.session_id for s in result] assert mcp_session.session_id in session_ids def test_list_sessions_empty_server(mcp_server: Server) -> None: - """list_sessions returns empty array when no sessions.""" + """list_sessions returns empty list when no sessions.""" # Kill all sessions first for s in mcp_server.sessions: s.kill() result = list_sessions(socket_name=mcp_server.socket_name) - data = json.loads(result) - assert data == [] + assert result == [] def test_create_session(mcp_server: Server) -> None: @@ -45,9 +42,8 @@ def test_create_session(mcp_server: Server) -> None: session_name="mcp_test_new", socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert data["session_name"] == "mcp_test_new" - assert data["session_id"] is not None + assert result.session_name == "mcp_test_new" + assert result.session_id is not None def test_create_session_duplicate(mcp_server: Server, mcp_session: Session) -> None: @@ -64,9 +60,8 @@ def test_create_session_duplicate(mcp_server: Server, mcp_session: Session) -> N def test_get_server_info(mcp_server: Server, mcp_session: Session) -> None: """get_server_info returns server status.""" result = get_server_info(socket_name=mcp_server.socket_name) - data = json.loads(result) - assert data["is_alive"] is True - assert data["session_count"] >= 1 + assert result.is_alive is True + assert result.session_count >= 1 class ListSessionsFilterFixture(t.NamedTuple): @@ -189,12 +184,11 @@ def test_list_sessions_with_filters( socket_name=mcp_server.socket_name, filters=filters, ) - data = json.loads(result) - assert isinstance(data, list) + assert isinstance(result, list) if expected_count is not None: - assert len(data) == expected_count + assert len(result) == expected_count else: - assert len(data) >= 1 + assert len(result) >= 1 def test_kill_server(mcp_server: Server, mcp_session: Session) -> None: diff --git a/tests/mcp/test_session_tools.py b/tests/mcp/test_session_tools.py index 9196f0d2b..f44030b77 100644 --- a/tests/mcp/test_session_tools.py +++ b/tests/mcp/test_session_tools.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import typing as t import pytest @@ -21,15 +20,14 @@ def test_list_windows(mcp_server: Server, mcp_session: Session) -> None: - """list_windows returns JSON array of windows.""" + """list_windows returns a list of WindowInfo models.""" result = list_windows( session_name=mcp_session.session_name, socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert isinstance(data, list) - assert len(data) >= 1 - assert "window_id" in data[0] + assert isinstance(result, list) + assert len(result) >= 1 + assert result[0].window_id is not None def test_list_windows_by_id(mcp_server: Server, mcp_session: Session) -> None: @@ -38,8 +36,7 @@ def test_list_windows_by_id(mcp_server: Server, mcp_session: Session) -> None: session_id=mcp_session.session_id, socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert len(data) >= 1 + assert len(result) >= 1 def test_create_window(mcp_server: Server, mcp_session: Session) -> None: @@ -49,8 +46,7 @@ def test_create_window(mcp_server: Server, mcp_session: Session) -> None: window_name="mcp_test_win", socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert data["window_name"] == "mcp_test_win" + assert result.window_name == "mcp_test_win" def test_create_window_invalid_direction( @@ -74,8 +70,7 @@ def test_rename_session(mcp_server: Server, mcp_session: Session) -> None: session_name=original_name, socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert data["session_name"] == "mcp_renamed" + assert result.session_name == "mcp_renamed" class ListWindowsFilterFixture(t.NamedTuple): @@ -189,9 +184,8 @@ def test_list_windows_with_filters( list_windows(**kwargs) else: result = list_windows(**kwargs) - data = json.loads(result) - assert isinstance(data, list) - assert len(data) >= expected_min_count + assert isinstance(result, list) + assert len(result) >= expected_min_count # Cleanup cross_win.kill() diff --git a/tests/mcp/test_utils.py b/tests/mcp/test_utils.py index 4bea6f9a5..545c50edf 100644 --- a/tests/mcp/test_utils.py +++ b/tests/mcp/test_utils.py @@ -104,29 +104,37 @@ def test_resolve_pane_not_found(mcp_server: Server, mcp_session: Session) -> Non def test_serialize_session(mcp_session: Session) -> None: - """_serialize_session produces expected keys.""" + """_serialize_session produces a SessionInfo model.""" + from libtmux.mcp.models import SessionInfo + data = _serialize_session(mcp_session) - assert "session_id" in data - assert "session_name" in data - assert "window_count" in data - assert data["session_id"] == mcp_session.session_id + assert isinstance(data, SessionInfo) + assert data.session_id == mcp_session.session_id + assert data.session_name is not None + assert data.window_count >= 0 def test_serialize_window(mcp_window: Window) -> None: - """_serialize_window produces expected keys.""" + """_serialize_window produces a WindowInfo model.""" + from libtmux.mcp.models import WindowInfo + data = _serialize_window(mcp_window) - assert "window_id" in data - assert "window_name" in data - assert "window_index" in data - assert "pane_count" in data + assert isinstance(data, WindowInfo) + assert data.window_id is not None + assert data.window_name is not None + assert data.window_index is not None + assert data.pane_count >= 0 def test_serialize_pane(mcp_pane: Pane) -> None: - """_serialize_pane produces expected keys.""" + """_serialize_pane produces a PaneInfo model.""" + from libtmux.mcp.models import PaneInfo + data = _serialize_pane(mcp_pane) - assert "pane_id" in data - assert "window_id" in data - assert "session_id" in data + assert isinstance(data, PaneInfo) + assert data.pane_id is not None + assert data.window_id is not None + assert data.session_id is not None def test_get_server_evicts_dead(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/mcp/test_window_tools.py b/tests/mcp/test_window_tools.py index a9f6fff05..a84c5411c 100644 --- a/tests/mcp/test_window_tools.py +++ b/tests/mcp/test_window_tools.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import typing as t import pytest @@ -23,16 +22,15 @@ def test_list_panes(mcp_server: Server, mcp_session: Session) -> None: - """list_panes returns JSON array of panes.""" + """list_panes returns a list of PaneInfo models.""" window = mcp_session.active_window result = list_panes( window_id=window.window_id, socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert isinstance(data, list) - assert len(data) >= 1 - assert "pane_id" in data[0] + assert isinstance(result, list) + assert len(result) >= 1 + assert result[0].pane_id is not None def test_split_window(mcp_server: Server, mcp_session: Session) -> None: @@ -43,8 +41,7 @@ def test_split_window(mcp_server: Server, mcp_session: Session) -> None: window_id=window.window_id, socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert "pane_id" in data + assert result.pane_id is not None assert len(window.panes) == initial_pane_count + 1 @@ -56,8 +53,7 @@ def test_split_window_with_direction(mcp_server: Server, mcp_session: Session) - direction="right", socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert "pane_id" in data + assert result.pane_id is not None def test_split_window_invalid_direction( @@ -81,8 +77,7 @@ def test_rename_window(mcp_server: Server, mcp_session: Session) -> None: window_id=window.window_id, socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert data["window_name"] == "mcp_renamed_win" + assert result.window_name == "mcp_renamed_win" def test_select_layout(mcp_server: Server, mcp_session: Session) -> None: @@ -94,8 +89,7 @@ def test_select_layout(mcp_server: Server, mcp_session: Session) -> None: window_id=window.window_id, socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert "window_id" in data + assert result.window_id is not None def test_resize_window(mcp_server: Server, mcp_session: Session) -> None: @@ -107,8 +101,7 @@ def test_resize_window(mcp_server: Server, mcp_session: Session) -> None: width=60, socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert data["window_id"] == window.window_id + assert result.window_id == window.window_id class ListPanesFilterFixture(t.NamedTuple): @@ -205,9 +198,8 @@ def test_list_panes_with_filters( list_panes(**kwargs) else: result = list_panes(**kwargs) - data = json.loads(result) - assert isinstance(data, list) - assert len(data) >= expected_min_count + assert isinstance(result, list) + assert len(result) >= expected_min_count def test_kill_window(mcp_server: Server, mcp_session: Session) -> None: From a4d8d14080076f9972ad0175d8535b7a82072100 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 12:04:16 -0500 Subject: [PATCH 33/47] mcp(feat[server]): Add content-vs-metadata guidance to server instructions why: When users ask what panes "contain" or "mention", LLMs default to metadata-only filters (e.g. window_name__contains) instead of reading pane contents. MCP server instructions are injected into the LLM's system prompt and are the primary mechanism for guiding tool selection workflows. what: - Add paragraph distinguishing metadata tools (list_*) from content tools - Reference search_panes and capture_pane as content-search approaches - Use trigger words ("contain", "mention", "show", "have") that match natural user language --- src/libtmux/mcp/server.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/libtmux/mcp/server.py b/src/libtmux/mcp/server.py index c77f46691..a4ff1b741 100644 --- a/src/libtmux/mcp/server.py +++ b/src/libtmux/mcp/server.py @@ -19,7 +19,13 @@ "it is globally unique within a tmux server. " "Use send_keys to execute commands and capture_pane to read output. " "All tools accept an optional socket_name parameter for multi-server " - "support (defaults to LIBTMUX_SOCKET env var)." + "support (defaults to LIBTMUX_SOCKET env var).\n\n" + "IMPORTANT — metadata vs content: list_windows, list_panes, and " + "list_sessions only search metadata (names, IDs, current command). " + "To find text that is actually visible in terminals — when users ask " + "what panes 'contain', 'mention', 'show', or 'have' — use " + "search_panes to search across all pane contents, or list_panes + " + "capture_pane on each pane for manual inspection." ), ) From 96ae62004c23624aecae02ae2a82bf8b7ff1b9ce Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 12:05:51 -0500 Subject: [PATCH 34/47] mcp(docs[tools]): Clarify metadata-only scope in list tool descriptions why: Tool descriptions are evaluated by LLMs at tool-selection time. Without explicit scope clarification, LLMs interpret "list windows mentioning X" as a metadata filter rather than a content search. Adding scope notes and cross-references to search_panes helps LLMs choose the correct tool. what: - Add metadata-only note to list_windows docstring - Add metadata-only note to list_panes docstring - Add cross-reference to search_panes in capture_pane docstring --- src/libtmux/mcp/tools/pane_tools.py | 3 +++ src/libtmux/mcp/tools/session_tools.py | 3 +++ src/libtmux/mcp/tools/window_tools.py | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index 2d2adaed2..dc4618a59 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -86,6 +86,9 @@ def capture_pane( ) -> str: """Capture the visible contents of a tmux pane. + This is the tool for reading what is displayed in a terminal. Use + search_panes to search for text across multiple panes at once. + Parameters ---------- pane_id : str, optional diff --git a/src/libtmux/mcp/tools/session_tools.py b/src/libtmux/mcp/tools/session_tools.py index 6ef642179..c45d764e5 100644 --- a/src/libtmux/mcp/tools/session_tools.py +++ b/src/libtmux/mcp/tools/session_tools.py @@ -28,6 +28,9 @@ def list_windows( ) -> list[WindowInfo]: """List windows in a tmux session, or all windows across sessions. + Only searches window metadata (name, index, layout). To search + the actual text visible in terminal panes, use search_panes instead. + Parameters ---------- session_name : str, optional diff --git a/src/libtmux/mcp/tools/window_tools.py b/src/libtmux/mcp/tools/window_tools.py index 5f6d7162f..216c95ffe 100644 --- a/src/libtmux/mcp/tools/window_tools.py +++ b/src/libtmux/mcp/tools/window_tools.py @@ -39,6 +39,10 @@ def list_panes( ) -> list[PaneInfo]: """List panes in a tmux window, session, or across the entire server. + Only searches pane metadata (current command, title, working directory). + To search the actual text visible in terminal panes, use search_panes + instead. + Parameters ---------- session_name : str, optional From 97abdcfa5fdc36d1eed19ec71b5f1302ca73102b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 12:12:29 -0500 Subject: [PATCH 35/47] mcp(feat[pane_tools]): Add search_panes tool for content search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: LLMs need a single tool to search for text visible in terminal panes. Without this, content search requires multi-step choreography (list_panes + capture_pane on each), which LLMs handle unreliably. The implementation uses tmux's native `list-panes -f "#{C:pattern}"` for a fast first pass — this runs `window_pane_search()` in C, searching the pane grid directly without serialization. Only matching panes are then captured to extract the actual matched lines. When the pattern contains regex metacharacters, falls back to capturing all panes (tmux's `#{C:}` uses glob matching, not regex). Refs: - https://github.com/tmux-python/libtmux/issues/645 - https://github.com/tmux-python/libtmux/issues/646 - http://blog.modelcontextprotocol.io/posts/2025-11-03-using-server-instructions/ what: - Add PaneContentMatch model to models.py - Add search_panes tool with two-phase tmux-optimized search - Register as read-only tool with "Search Panes" title - Add parametrized tests using SearchPanesFixture NamedTuple (7 cases) - Add standalone tests for model types, parent context, and error handling - Use retry_until instead of time.sleep for test reliability --- src/libtmux/mcp/models.py | 17 +++ src/libtmux/mcp/tools/pane_tools.py | 127 ++++++++++++++++- tests/mcp/test_pane_tools.py | 206 ++++++++++++++++++++++++++++ 3 files changed, 349 insertions(+), 1 deletion(-) diff --git a/src/libtmux/mcp/models.py b/src/libtmux/mcp/models.py index 08ffbb676..565a86967 100644 --- a/src/libtmux/mcp/models.py +++ b/src/libtmux/mcp/models.py @@ -58,6 +58,23 @@ class PaneInfo(BaseModel): session_id: str | None = Field(default=None, description="Parent session ID") +class PaneContentMatch(BaseModel): + """A pane whose captured content matched a search pattern.""" + + pane_id: str = Field(description="Pane ID (e.g. '%1')") + pane_current_command: str | None = Field( + default=None, description="Running command" + ) + pane_current_path: str | None = Field( + default=None, description="Current working directory" + ) + window_id: str | None = Field(default=None, description="Parent window ID") + window_name: str | None = Field(default=None, description="Parent window name") + session_id: str | None = Field(default=None, description="Parent session ID") + session_name: str | None = Field(default=None, description="Parent session name") + matched_lines: list[str] = Field(description="Lines containing the match") + + class ServerInfo(BaseModel): """Serialized tmux server info.""" diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index dc4618a59..c1b9a62bf 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -2,15 +2,17 @@ from __future__ import annotations +import re import typing as t from libtmux.mcp._utils import ( _get_server, _resolve_pane, + _resolve_session, _serialize_pane, handle_tool_errors, ) -from libtmux.mcp.models import PaneInfo +from libtmux.mcp.models import PaneContentMatch, PaneInfo if t.TYPE_CHECKING: from fastmcp import FastMCP @@ -351,6 +353,128 @@ def clear_pane( return f"Pane cleared: {pane.pane_id}" +@handle_tool_errors +def search_panes( + pattern: str, + session_name: str | None = None, + session_id: str | None = None, + match_case: bool = False, + content_start: int | None = None, + content_end: int | None = None, + socket_name: str | None = None, +) -> list[PaneContentMatch]: + """Search for text across all pane contents. + + Use this when users ask what panes 'contain', 'mention', or 'show'. + Searches each pane's visible content and returns panes where the + pattern is found, with matching lines. + + Parameters + ---------- + pattern : str + Text or regex pattern to search for in pane contents. + session_name : str, optional + Limit search to panes in this session. + session_id : str, optional + Limit search to panes in this session (by ID). + match_case : bool + Whether to match case. Default False (case-insensitive). + content_start : int, optional + Start line for capture. Negative values reach into scrollback. + content_end : int, optional + End line for capture. + socket_name : str, optional + tmux socket name. + + Returns + ------- + list[PaneContentMatch] + Panes with matching content, including matched lines. + """ + from fastmcp.exceptions import ToolError + + flags = 0 if match_case else re.IGNORECASE + try: + compiled = re.compile(pattern, flags) + except re.error as e: + msg = f"Invalid regex pattern: {e}" + raise ToolError(msg) from e + + server = _get_server(socket_name=socket_name) + + uses_scrollback = content_start is not None or content_end is not None + + # Detect if pattern contains regex metacharacters that would break + # tmux's glob-based #{C:} filter. When regex is needed, skip the tmux + # fast path and capture all panes for Python-side matching. + _REGEX_META = re.compile(r"[\\.*+?{}()\[\]|^$]") + is_plain_text = not _REGEX_META.search(pattern) + + if not uses_scrollback and is_plain_text: + # Phase 1: Fast filter via tmux's C-level window_pane_search(). + # #{C/i:pattern} searches visible pane content in C, returning only + # matching pane IDs without capturing full content. + case_flag = "" if match_case else "i" + tmux_filter = ( + f"#{{C/{case_flag}:{pattern}}}" if case_flag else f"#{{C:{pattern}}}" + ) + + cmd_args: list[str] = ["list-panes"] + if session_name is not None or session_id is not None: + session = _resolve_session( + server, session_name=session_name, session_id=session_id + ) + cmd_args.extend(["-t", session.session_id or ""]) + cmd_args.append("-s") + else: + cmd_args.append("-a") + cmd_args.extend(["-f", tmux_filter, "-F", "#{pane_id}"]) + + result = server.cmd(*cmd_args) + matching_pane_ids = set(result.stdout) if result.stdout else set() + else: + # Regex pattern or scrollback requested — fall back to capturing + # all panes and matching in Python. + if session_name is not None or session_id is not None: + session = _resolve_session( + server, session_name=session_name, session_id=session_id + ) + all_panes = session.panes + else: + all_panes = server.panes + matching_pane_ids = {p.pane_id for p in all_panes if p.pane_id is not None} + + # Phase 2: Capture matching panes and extract matched lines. + matches: list[PaneContentMatch] = [] + for pane_id_str in matching_pane_ids: + pane = server.panes.get(pane_id=pane_id_str, default=None) + if pane is None: + continue + + lines = pane.capture_pane(start=content_start, end=content_end) + matched_lines = [line for line in lines if compiled.search(line)] + + if not matched_lines: + continue + + window = pane.window + session_obj = pane.session + matches.append( + PaneContentMatch( + pane_id=pane_id_str, + pane_current_command=getattr(pane, "pane_current_command", None), + pane_current_path=getattr(pane, "pane_current_path", None), + window_id=pane.window_id, + window_name=getattr(window, "window_name", None), + session_id=pane.session_id, + session_name=getattr(session_obj, "session_name", None), + matched_lines=matched_lines, + ) + ) + + return matches + + def register(mcp: FastMCP) -> None: """Register pane-level tools with the MCP instance.""" _RO = { @@ -388,3 +512,4 @@ def register(mcp: FastMCP) -> None: mcp.tool(title="Set Pane Title", annotations=_IDEM)(set_pane_title) mcp.tool(title="Get Pane Info", annotations=_RO)(get_pane_info) mcp.tool(title="Clear Pane", annotations=_IDEM)(clear_pane) + mcp.tool(title="Search Panes", annotations=_RO)(search_panes) diff --git a/tests/mcp/test_pane_tools.py b/tests/mcp/test_pane_tools.py index 318da142f..12b8e3c92 100644 --- a/tests/mcp/test_pane_tools.py +++ b/tests/mcp/test_pane_tools.py @@ -7,15 +7,18 @@ import pytest from fastmcp.exceptions import ToolError +from libtmux.mcp.models import PaneContentMatch from libtmux.mcp.tools.pane_tools import ( capture_pane, clear_pane, get_pane_info, kill_pane, resize_pane, + search_panes, send_keys, set_pane_title, ) +from libtmux.test.retry import retry_until if t.TYPE_CHECKING: from libtmux.pane import Pane @@ -120,3 +123,206 @@ def test_kill_pane(mcp_server: Server, mcp_session: Session) -> None: socket_name=mcp_server.socket_name, ) assert "killed" in result.lower() + + +# --------------------------------------------------------------------------- +# search_panes tests +# --------------------------------------------------------------------------- + + +class SearchPanesFixture(t.NamedTuple): + """Test fixture for search_panes.""" + + test_id: str + command: str + pattern: str + match_case: bool + scope_to_session: bool + expected_match: bool + expected_min_lines: int + + +SEARCH_PANES_FIXTURES: list[SearchPanesFixture] = [ + SearchPanesFixture( + test_id="simple_match", + command="echo FINDME_unique_string_12345", + pattern="FINDME_unique_string_12345", + match_case=False, + scope_to_session=False, + expected_match=True, + expected_min_lines=1, + ), + SearchPanesFixture( + test_id="case_insensitive_match", + command="echo UPPERCASE_findme_test", + pattern="uppercase_findme_test", + match_case=False, + scope_to_session=False, + expected_match=True, + expected_min_lines=1, + ), + SearchPanesFixture( + test_id="case_sensitive_no_match", + command="echo CaseSensitiveTest", + pattern="casesensitivetest", + match_case=True, + scope_to_session=False, + expected_match=False, + expected_min_lines=0, + ), + SearchPanesFixture( + test_id="case_sensitive_match", + command="echo CaseSensitiveExact", + pattern="CaseSensitiveExact", + match_case=True, + scope_to_session=False, + expected_match=True, + expected_min_lines=1, + ), + SearchPanesFixture( + test_id="regex_pattern", + command="echo error_code_42_found", + pattern=r"error_code_\d+_found", + match_case=False, + scope_to_session=False, + expected_match=True, + expected_min_lines=1, + ), + SearchPanesFixture( + test_id="no_match", + command="echo nothing_special", + pattern="XYZZY_nonexistent_pattern_99999", + match_case=False, + scope_to_session=False, + expected_match=False, + expected_min_lines=0, + ), + SearchPanesFixture( + test_id="scoped_to_session", + command="echo session_scoped_marker", + pattern="session_scoped_marker", + match_case=False, + scope_to_session=True, + expected_match=True, + expected_min_lines=1, + ), +] + + +@pytest.mark.parametrize( + SearchPanesFixture._fields, + SEARCH_PANES_FIXTURES, + ids=[f.test_id for f in SEARCH_PANES_FIXTURES], +) +def test_search_panes( + mcp_server: Server, + mcp_session: Session, + mcp_pane: Pane, + test_id: str, + command: str, + pattern: str, + match_case: bool, + scope_to_session: bool, + expected_match: bool, + expected_min_lines: int, +) -> None: + """search_panes finds text in pane contents.""" + # Extract the echoed text from the command for polling + echo_marker = command.split("echo ", 1)[1] if "echo " in command else command + mcp_pane.send_keys(command, enter=True) + retry_until( + lambda: echo_marker in "\n".join(mcp_pane.capture_pane()), + 2, + raises=True, + ) + + kwargs: dict[str, t.Any] = { + "pattern": pattern, + "match_case": match_case, + "socket_name": mcp_server.socket_name, + } + if scope_to_session: + kwargs["session_name"] = mcp_session.session_name + + result = search_panes(**kwargs) + assert isinstance(result, list) + + if expected_match: + assert len(result) >= 1 + match = next((r for r in result if r.pane_id == mcp_pane.pane_id), None) + assert match is not None + assert len(match.matched_lines) >= expected_min_lines + assert match.session_id is not None + assert match.window_id is not None + else: + pane_matches = [r for r in result if r.pane_id == mcp_pane.pane_id] + assert len(pane_matches) == 0 + + +def test_search_panes_basic(mcp_server: Server, mcp_pane: Pane) -> None: + """search_panes smoke test with a unique marker.""" + mcp_pane.send_keys("echo SMOKE_TEST_MARKER_abc123", enter=True) + retry_until( + lambda: "SMOKE_TEST_MARKER_abc123" in "\n".join(mcp_pane.capture_pane()), + 2, + raises=True, + ) + + result = search_panes( + pattern="SMOKE_TEST_MARKER_abc123", + socket_name=mcp_server.socket_name, + ) + assert isinstance(result, list) + assert len(result) >= 1 + assert any(r.pane_id == mcp_pane.pane_id for r in result) + + +def test_search_panes_returns_pane_content_match_model( + mcp_server: Server, mcp_pane: Pane +) -> None: + """search_panes returns PaneContentMatch models.""" + mcp_pane.send_keys("echo MODEL_TYPE_CHECK_xyz", enter=True) + retry_until( + lambda: "MODEL_TYPE_CHECK_xyz" in "\n".join(mcp_pane.capture_pane()), + 2, + raises=True, + ) + + result = search_panes( + pattern="MODEL_TYPE_CHECK_xyz", + socket_name=mcp_server.socket_name, + ) + assert len(result) >= 1 + for item in result: + assert isinstance(item, PaneContentMatch) + + +def test_search_panes_includes_window_and_session_names( + mcp_server: Server, mcp_session: Session, mcp_pane: Pane +) -> None: + """search_panes populates window_name and session_name.""" + mcp_pane.send_keys("echo CONTEXT_FIELDS_CHECK_789", enter=True) + retry_until( + lambda: "CONTEXT_FIELDS_CHECK_789" in "\n".join(mcp_pane.capture_pane()), + 2, + raises=True, + ) + + result = search_panes( + pattern="CONTEXT_FIELDS_CHECK_789", + socket_name=mcp_server.socket_name, + ) + match = next((r for r in result if r.pane_id == mcp_pane.pane_id), None) + assert match is not None + assert match.window_name is not None + assert match.session_name is not None + assert match.session_name == mcp_session.session_name + + +def test_search_panes_invalid_regex(mcp_server: Server) -> None: + """search_panes raises ToolError on invalid regex.""" + with pytest.raises(ToolError, match="Invalid regex pattern"): + search_panes( + pattern="[invalid", + socket_name=mcp_server.socket_name, + ) From 9492e2aab39264a72be62b7dd78031b1abea41d5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 13:32:52 -0500 Subject: [PATCH 36/47] mcp(feat[server,models]): Add agent self-awareness via TMUX_PANE detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: When an LLM agent runs inside tmux, the MCP server inherits `TMUX_PANE` and `TMUX` environment variables. Without self-awareness, tools like `search_panes` return the agent's own pane in results with no way for the LLM to distinguish it from other panes — forcing a separate `echo $TMUX_PANE` shell command. This follows the "inform, never decide" constitutional principle: enrich metadata without changing tool behavior. what: - Add `_get_caller_pane_id()` helper to `_utils.py` reading `TMUX_PANE` env var - Add `is_caller: bool | None` field to `PaneInfo` and `PaneContentMatch` models - Annotate `_serialize_pane()` and `search_panes()` results with `is_caller` - Add `_build_instructions()` to `server.py` that appends agent tmux context (pane ID, socket name) to server instructions when `TMUX_PANE` is set - Add parametrized tests for serializer, search_panes, and instructions builder --- src/libtmux/mcp/_utils.py | 8 +++ src/libtmux/mcp/models.py | 14 +++++ src/libtmux/mcp/server.py | 69 +++++++++++++++----- src/libtmux/mcp/tools/pane_tools.py | 3 + tests/mcp/test_pane_tools.py | 69 ++++++++++++++++++++ tests/mcp/test_server.py | 98 +++++++++++++++++++++++++++++ tests/mcp/test_utils.py | 74 ++++++++++++++++++++++ 7 files changed, 320 insertions(+), 15 deletions(-) create mode 100644 tests/mcp/test_server.py diff --git a/src/libtmux/mcp/_utils.py b/src/libtmux/mcp/_utils.py index 9f4598d60..f10a1b224 100644 --- a/src/libtmux/mcp/_utils.py +++ b/src/libtmux/mcp/_utils.py @@ -25,6 +25,12 @@ logger = logging.getLogger(__name__) + +def _get_caller_pane_id() -> str | None: + """Return the TMUX_PANE of the calling process, or None if not in tmux.""" + return os.environ.get("TMUX_PANE") + + _server_cache: dict[tuple[str | None, str | None, str | None], Server] = {} _server_cache_lock = threading.Lock() @@ -424,6 +430,7 @@ def _serialize_pane(pane: Pane) -> PaneInfo: from libtmux.mcp.models import PaneInfo assert pane.pane_id is not None + caller_pane_id = _get_caller_pane_id() return PaneInfo( pane_id=pane.pane_id, pane_index=getattr(pane, "pane_index", None), @@ -436,6 +443,7 @@ def _serialize_pane(pane: Pane) -> PaneInfo: pane_active=getattr(pane, "pane_active", None), window_id=pane.window_id, session_id=pane.session_id, + is_caller=pane.pane_id == caller_pane_id if caller_pane_id else None, ) diff --git a/src/libtmux/mcp/models.py b/src/libtmux/mcp/models.py index 565a86967..e7f63b953 100644 --- a/src/libtmux/mcp/models.py +++ b/src/libtmux/mcp/models.py @@ -56,6 +56,13 @@ class PaneInfo(BaseModel): ) window_id: str | None = Field(default=None, description="Parent window ID") session_id: str | None = Field(default=None, description="Parent session ID") + is_caller: bool | None = Field( + default=None, + description=( + "True if this pane is the MCP caller's own pane " + "(detected via TMUX_PANE env var)" + ), + ) class PaneContentMatch(BaseModel): @@ -73,6 +80,13 @@ class PaneContentMatch(BaseModel): session_id: str | None = Field(default=None, description="Parent session ID") session_name: str | None = Field(default=None, description="Parent session name") matched_lines: list[str] = Field(description="Lines containing the match") + is_caller: bool | None = Field( + default=None, + description=( + "True if this pane is the MCP caller's own pane " + "(detected via TMUX_PANE env var)" + ), + ) class ServerInfo(BaseModel): diff --git a/src/libtmux/mcp/server.py b/src/libtmux/mcp/server.py index a4ff1b741..47877f12f 100644 --- a/src/libtmux/mcp/server.py +++ b/src/libtmux/mcp/server.py @@ -5,28 +5,67 @@ from __future__ import annotations +import os + from fastmcp import FastMCP from libtmux.__about__ import __version__ +_BASE_INSTRUCTIONS = ( + "libtmux MCP server for programmatic tmux control. " + "tmux hierarchy: Server > Session > Window > Pane. " + "Use pane_id (e.g. '%1') as the preferred targeting method - " + "it is globally unique within a tmux server. " + "Use send_keys to execute commands and capture_pane to read output. " + "All tools accept an optional socket_name parameter for multi-server " + "support (defaults to LIBTMUX_SOCKET env var).\n\n" + "IMPORTANT — metadata vs content: list_windows, list_panes, and " + "list_sessions only search metadata (names, IDs, current command). " + "To find text that is actually visible in terminals — when users ask " + "what panes 'contain', 'mention', 'show', or 'have' — use " + "search_panes to search across all pane contents, or list_panes + " + "capture_pane on each pane for manual inspection." +) + + +def _build_instructions() -> str: + """Build server instructions, appending agent context if inside tmux. + + When the MCP server process runs inside a tmux pane, ``TMUX_PANE`` and + ``TMUX`` environment variables are available. This function appends that + context so the LLM knows which pane is its own without extra tool calls. + + Returns + ------- + str + Server instructions string, optionally with agent tmux context. + """ + tmux_pane = os.environ.get("TMUX_PANE") + if not tmux_pane: + return _BASE_INSTRUCTIONS + + # Parse TMUX env: "/tmp/tmux-1000/default,48188,10" + tmux_env = os.environ.get("TMUX", "") + parts = tmux_env.split(",") if tmux_env else [] + socket_path = parts[0] if parts else None + socket_name = socket_path.rsplit("/", 1)[-1] if socket_path else None + + context = ( + f"\n\nAgent context: This MCP server is running inside tmux pane {tmux_pane}" + ) + if socket_name: + context += f" (socket: {socket_name})" + context += ( + ". Tool results annotate the caller's own pane with " + "is_caller=true. Use this to distinguish your own pane from others." + ) + return _BASE_INSTRUCTIONS + context + + mcp = FastMCP( name="libtmux", version=__version__, - instructions=( - "libtmux MCP server for programmatic tmux control. " - "tmux hierarchy: Server > Session > Window > Pane. " - "Use pane_id (e.g. '%1') as the preferred targeting method - " - "it is globally unique within a tmux server. " - "Use send_keys to execute commands and capture_pane to read output. " - "All tools accept an optional socket_name parameter for multi-server " - "support (defaults to LIBTMUX_SOCKET env var).\n\n" - "IMPORTANT — metadata vs content: list_windows, list_panes, and " - "list_sessions only search metadata (names, IDs, current command). " - "To find text that is actually visible in terminals — when users ask " - "what panes 'contain', 'mention', 'show', or 'have' — use " - "search_panes to search across all pane contents, or list_panes + " - "capture_pane on each pane for manual inspection." - ), + instructions=_build_instructions(), ) diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index c1b9a62bf..ba4967441 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -6,6 +6,7 @@ import typing as t from libtmux.mcp._utils import ( + _get_caller_pane_id, _get_server, _resolve_pane, _resolve_session, @@ -445,6 +446,7 @@ def search_panes( matching_pane_ids = {p.pane_id for p in all_panes if p.pane_id is not None} # Phase 2: Capture matching panes and extract matched lines. + caller_pane_id = _get_caller_pane_id() matches: list[PaneContentMatch] = [] for pane_id_str in matching_pane_ids: pane = server.panes.get(pane_id=pane_id_str, default=None) @@ -469,6 +471,7 @@ def search_panes( session_id=pane.session_id, session_name=getattr(session_obj, "session_name", None), matched_lines=matched_lines, + is_caller=(pane_id_str == caller_pane_id if caller_pane_id else None), ) ) diff --git a/tests/mcp/test_pane_tools.py b/tests/mcp/test_pane_tools.py index 12b8e3c92..33d3ec115 100644 --- a/tests/mcp/test_pane_tools.py +++ b/tests/mcp/test_pane_tools.py @@ -326,3 +326,72 @@ def test_search_panes_invalid_regex(mcp_server: Server) -> None: pattern="[invalid", socket_name=mcp_server.socket_name, ) + + +# --------------------------------------------------------------------------- +# search_panes is_caller annotation tests +# --------------------------------------------------------------------------- + + +class SearchPanesCallerFixture(t.NamedTuple): + """Test fixture for search_panes is_caller annotation.""" + + test_id: str + tmux_pane_env: str | None + use_real_pane_id: bool + expected_is_caller: bool | None + + +SEARCH_PANES_CALLER_FIXTURES: list[SearchPanesCallerFixture] = [ + SearchPanesCallerFixture( + test_id="caller_pane_annotated", + tmux_pane_env=None, + use_real_pane_id=True, + expected_is_caller=True, + ), + SearchPanesCallerFixture( + test_id="outside_tmux_no_annotation", + tmux_pane_env=None, + use_real_pane_id=False, + expected_is_caller=None, + ), +] + + +@pytest.mark.parametrize( + SearchPanesCallerFixture._fields, + SEARCH_PANES_CALLER_FIXTURES, + ids=[f.test_id for f in SEARCH_PANES_CALLER_FIXTURES], +) +def test_search_panes_is_caller( + mcp_server: Server, + mcp_pane: Pane, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + tmux_pane_env: str | None, + use_real_pane_id: bool, + expected_is_caller: bool | None, +) -> None: + """search_panes annotates results with is_caller based on TMUX_PANE.""" + marker = f"IS_CALLER_TEST_{test_id}_{id(mcp_pane)}" + mcp_pane.send_keys(f"echo {marker}", enter=True) + retry_until( + lambda: marker in "\n".join(mcp_pane.capture_pane()), + 2, + raises=True, + ) + + if use_real_pane_id: + monkeypatch.setenv("TMUX_PANE", mcp_pane.pane_id or "") + elif tmux_pane_env is not None: + monkeypatch.setenv("TMUX_PANE", tmux_pane_env) + else: + monkeypatch.delenv("TMUX_PANE", raising=False) + + result = search_panes( + pattern=marker, + socket_name=mcp_server.socket_name, + ) + match = next((r for r in result if r.pane_id == mcp_pane.pane_id), None) + assert match is not None + assert match.is_caller is expected_is_caller diff --git a/tests/mcp/test_server.py b/tests/mcp/test_server.py new file mode 100644 index 000000000..95f6fd243 --- /dev/null +++ b/tests/mcp/test_server.py @@ -0,0 +1,98 @@ +"""Tests for libtmux MCP server configuration.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.mcp.server import _BASE_INSTRUCTIONS, _build_instructions + + +class BuildInstructionsFixture(t.NamedTuple): + """Test fixture for _build_instructions.""" + + test_id: str + tmux_pane_env: str | None + tmux_env: str | None + expect_agent_context: bool + expect_pane_id_in_text: str | None + expect_socket_name: str | None + + +BUILD_INSTRUCTIONS_FIXTURES: list[BuildInstructionsFixture] = [ + BuildInstructionsFixture( + test_id="inside_tmux_full_context", + tmux_pane_env="%42", + tmux_env="/tmp/tmux-1000/default,12345,0", + expect_agent_context=True, + expect_pane_id_in_text="%42", + expect_socket_name="default", + ), + BuildInstructionsFixture( + test_id="outside_tmux_no_context", + tmux_pane_env=None, + tmux_env=None, + expect_agent_context=False, + expect_pane_id_in_text=None, + expect_socket_name=None, + ), + BuildInstructionsFixture( + test_id="pane_only_no_tmux_env", + tmux_pane_env="%99", + tmux_env=None, + expect_agent_context=True, + expect_pane_id_in_text="%99", + expect_socket_name=None, + ), +] + + +@pytest.mark.parametrize( + BuildInstructionsFixture._fields, + BUILD_INSTRUCTIONS_FIXTURES, + ids=[f.test_id for f in BUILD_INSTRUCTIONS_FIXTURES], +) +def test_build_instructions( + monkeypatch: pytest.MonkeyPatch, + test_id: str, + tmux_pane_env: str | None, + tmux_env: str | None, + expect_agent_context: bool, + expect_pane_id_in_text: str | None, + expect_socket_name: str | None, +) -> None: + """_build_instructions includes agent context when inside tmux.""" + if tmux_pane_env is not None: + monkeypatch.setenv("TMUX_PANE", tmux_pane_env) + else: + monkeypatch.delenv("TMUX_PANE", raising=False) + + if tmux_env is not None: + monkeypatch.setenv("TMUX", tmux_env) + else: + monkeypatch.delenv("TMUX", raising=False) + + result = _build_instructions() + + # Base instructions are always present + assert _BASE_INSTRUCTIONS in result + + if expect_agent_context: + assert "Agent context" in result + else: + assert "Agent context" not in result + + if expect_pane_id_in_text is not None: + assert expect_pane_id_in_text in result + + if expect_socket_name is not None: + assert expect_socket_name in result + + +def test_base_instructions_content() -> None: + """_BASE_INSTRUCTIONS contains key guidance for the LLM.""" + assert "tmux hierarchy" in _BASE_INSTRUCTIONS + assert "pane_id" in _BASE_INSTRUCTIONS + assert "search_panes" in _BASE_INSTRUCTIONS + assert "metadata vs content" in _BASE_INSTRUCTIONS diff --git a/tests/mcp/test_utils.py b/tests/mcp/test_utils.py index 545c50edf..775c357af 100644 --- a/tests/mcp/test_utils.py +++ b/tests/mcp/test_utils.py @@ -10,6 +10,7 @@ from libtmux import exc from libtmux.mcp._utils import ( _apply_filters, + _get_caller_pane_id, _get_server, _invalidate_server, _resolve_pane, @@ -293,3 +294,76 @@ def test_apply_filters( assert len(result) == expected_count else: assert len(result) >= 1 + + +# --------------------------------------------------------------------------- +# _get_caller_pane_id / _serialize_pane is_caller tests +# --------------------------------------------------------------------------- + + +def test_get_caller_pane_id_returns_env(monkeypatch: pytest.MonkeyPatch) -> None: + """_get_caller_pane_id returns TMUX_PANE when set.""" + monkeypatch.setenv("TMUX_PANE", "%42") + assert _get_caller_pane_id() == "%42" + + +def test_get_caller_pane_id_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: + """_get_caller_pane_id returns None outside tmux.""" + monkeypatch.delenv("TMUX_PANE", raising=False) + assert _get_caller_pane_id() is None + + +class SerializePaneCallerFixture(t.NamedTuple): + """Test fixture for _serialize_pane is_caller annotation.""" + + test_id: str + tmux_pane_env: str | None + use_real_pane_id: bool + expected_is_caller: bool | None + + +SERIALIZE_PANE_CALLER_FIXTURES: list[SerializePaneCallerFixture] = [ + SerializePaneCallerFixture( + test_id="matching_pane_id", + tmux_pane_env=None, + use_real_pane_id=True, + expected_is_caller=True, + ), + SerializePaneCallerFixture( + test_id="non_matching_pane_id", + tmux_pane_env="%99999", + use_real_pane_id=False, + expected_is_caller=False, + ), + SerializePaneCallerFixture( + test_id="unset_outside_tmux", + tmux_pane_env=None, + use_real_pane_id=False, + expected_is_caller=None, + ), +] + + +@pytest.mark.parametrize( + SerializePaneCallerFixture._fields, + SERIALIZE_PANE_CALLER_FIXTURES, + ids=[f.test_id for f in SERIALIZE_PANE_CALLER_FIXTURES], +) +def test_serialize_pane_is_caller( + mcp_pane: Pane, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + tmux_pane_env: str | None, + use_real_pane_id: bool, + expected_is_caller: bool | None, +) -> None: + """_serialize_pane sets is_caller based on TMUX_PANE env var.""" + if use_real_pane_id: + monkeypatch.setenv("TMUX_PANE", mcp_pane.pane_id or "") + elif tmux_pane_env is not None: + monkeypatch.setenv("TMUX_PANE", tmux_pane_env) + else: + monkeypatch.delenv("TMUX_PANE", raising=False) + + data = _serialize_pane(mcp_pane) + assert data.is_caller is expected_is_caller From acdbf7074ca0cc11dab23e115e7822c36dac515e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 14:15:41 -0500 Subject: [PATCH 37/47] mcp(fix[pane_tools]): Correct send_keys destructiveHint to False MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: `send_keys` sends keystrokes to a terminal — it's a side-effecting action, not a destructive one. MCP's `destructiveHint` means "may perform destructive updates" like killing sessions or deleting data. MCP clients gate destructive tools behind extra confirmation dialogs, adding unnecessary friction to the most-used tool in the server. Refs: - https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/ what: - Change `destructiveHint` from `True` to `False` in send_keys registration --- src/libtmux/mcp/tools/pane_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index ba4967441..fd4f96be4 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -496,7 +496,7 @@ def register(mcp: FastMCP) -> None: title="Send Keys", annotations={ "readOnlyHint": False, - "destructiveHint": True, + "destructiveHint": False, "idempotentHint": False, "openWorldHint": False, }, From b72f4df76ae56b954583a7ed19efbbb14b2761dc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 14:19:34 -0500 Subject: [PATCH 38/47] mcp(refactor[tools]): Extract annotation constants and add safety tier tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Annotation dicts (`_RO`, `_IDEM`, inline destructive dicts) were duplicated identically across all 6 tool modules — a DRY violation that makes maintenance error-prone. Additionally, tools had no programmatic categorization beyond annotations, preventing middleware-based safety gating. Refs: - https://gofastmcp.com/servers/tools (tags parameter) - https://gofastmcp.com/servers/middleware (tag-based filtering) what: - Add TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE constants to _utils.py - Add ANNOTATIONS_RO, ANNOTATIONS_MUTATING, ANNOTATIONS_CREATE, ANNOTATIONS_DESTRUCTIVE presets to _utils.py - Add VALID_SAFETY_LEVELS frozenset for validation - Replace all local _RO/_IDEM/inline dicts in 6 tool modules with imports - Add tags={TAG_*} to all 25 mcp.tool() registrations - Add tests for constant correctness and completeness --- src/libtmux/mcp/_utils.py | 40 +++++++++++++++++ src/libtmux/mcp/tools/env_tools.py | 22 ++++------ src/libtmux/mcp/tools/option_tools.py | 26 +++++------ src/libtmux/mcp/tools/pane_tools.py | 61 ++++++++++++-------------- src/libtmux/mcp/tools/server_tools.py | 36 ++++++--------- src/libtmux/mcp/tools/session_tools.py | 43 +++++++----------- src/libtmux/mcp/tools/window_tools.py | 53 ++++++++++------------ tests/mcp/test_utils.py | 54 +++++++++++++++++++++++ 8 files changed, 194 insertions(+), 141 deletions(-) diff --git a/src/libtmux/mcp/_utils.py b/src/libtmux/mcp/_utils.py index f10a1b224..084bf03f3 100644 --- a/src/libtmux/mcp/_utils.py +++ b/src/libtmux/mcp/_utils.py @@ -31,6 +31,46 @@ def _get_caller_pane_id() -> str | None: return os.environ.get("TMUX_PANE") +# --------------------------------------------------------------------------- +# Safety tier tags +# --------------------------------------------------------------------------- + +TAG_READONLY = "readonly" +TAG_MUTATING = "mutating" +TAG_DESTRUCTIVE = "destructive" + +VALID_SAFETY_LEVELS = frozenset({TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE}) + +# --------------------------------------------------------------------------- +# Reusable annotation presets for tool registration +# --------------------------------------------------------------------------- + +ANNOTATIONS_RO: dict[str, bool] = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, +} +ANNOTATIONS_MUTATING: dict[str, bool] = { + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, +} +ANNOTATIONS_CREATE: dict[str, bool] = { + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": False, +} +ANNOTATIONS_DESTRUCTIVE: dict[str, bool] = { + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": True, + "openWorldHint": False, +} + + _server_cache: dict[tuple[str | None, str | None, str | None], Server] = {} _server_cache_lock = threading.Lock() diff --git a/src/libtmux/mcp/tools/env_tools.py b/src/libtmux/mcp/tools/env_tools.py index 12dfb270d..04b98b376 100644 --- a/src/libtmux/mcp/tools/env_tools.py +++ b/src/libtmux/mcp/tools/env_tools.py @@ -6,6 +6,10 @@ import typing as t from libtmux.mcp._utils import ( + ANNOTATIONS_MUTATING, + ANNOTATIONS_RO, + TAG_MUTATING, + TAG_READONLY, _get_server, _resolve_session, handle_tool_errors, @@ -98,19 +102,9 @@ def set_environment( def register(mcp: FastMCP) -> None: """Register environment tools with the MCP instance.""" - _RO = { - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, - } - mcp.tool(title="Show Environment", annotations=_RO)(show_environment) + mcp.tool(title="Show Environment", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( + show_environment + ) mcp.tool( - title="Set Environment", - annotations={ - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, - }, + title="Set Environment", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} )(set_environment) diff --git a/src/libtmux/mcp/tools/option_tools.py b/src/libtmux/mcp/tools/option_tools.py index 7a6af1efe..4c6ba87ac 100644 --- a/src/libtmux/mcp/tools/option_tools.py +++ b/src/libtmux/mcp/tools/option_tools.py @@ -6,6 +6,10 @@ from libtmux.constants import OptionScope from libtmux.mcp._utils import ( + ANNOTATIONS_MUTATING, + ANNOTATIONS_RO, + TAG_MUTATING, + TAG_READONLY, _get_server, _resolve_pane, _resolve_session, @@ -134,19 +138,9 @@ def set_option( def register(mcp: FastMCP) -> None: """Register option tools with the MCP instance.""" - _RO = { - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, - } - mcp.tool(title="Show Option", annotations=_RO)(show_option) - mcp.tool( - title="Set Option", - annotations={ - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, - }, - )(set_option) + mcp.tool(title="Show Option", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( + show_option + ) + mcp.tool(title="Set Option", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING})( + set_option + ) diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index fd4f96be4..2a82c4dfa 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -6,6 +6,13 @@ import typing as t from libtmux.mcp._utils import ( + ANNOTATIONS_CREATE, + ANNOTATIONS_DESTRUCTIVE, + ANNOTATIONS_MUTATING, + ANNOTATIONS_RO, + TAG_DESTRUCTIVE, + TAG_MUTATING, + TAG_READONLY, _get_caller_pane_id, _get_server, _resolve_pane, @@ -480,39 +487,29 @@ def search_panes( def register(mcp: FastMCP) -> None: """Register pane-level tools with the MCP instance.""" - _RO = { - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, - } - _IDEM = { - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, - } + mcp.tool(title="Send Keys", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING})( + send_keys + ) + mcp.tool(title="Capture Pane", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( + capture_pane + ) mcp.tool( - title="Send Keys", - annotations={ - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": False, - "openWorldHint": False, - }, - )(send_keys) - mcp.tool(title="Capture Pane", annotations=_RO)(capture_pane) - mcp.tool(title="Resize Pane", annotations=_IDEM)(resize_pane) + title="Resize Pane", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} + )(resize_pane) mcp.tool( title="Kill Pane", - annotations={ - "readOnlyHint": False, - "destructiveHint": True, - "idempotentHint": True, - "openWorldHint": False, - }, + annotations=ANNOTATIONS_DESTRUCTIVE, + tags={TAG_DESTRUCTIVE}, )(kill_pane) - mcp.tool(title="Set Pane Title", annotations=_IDEM)(set_pane_title) - mcp.tool(title="Get Pane Info", annotations=_RO)(get_pane_info) - mcp.tool(title="Clear Pane", annotations=_IDEM)(clear_pane) - mcp.tool(title="Search Panes", annotations=_RO)(search_panes) + mcp.tool( + title="Set Pane Title", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} + )(set_pane_title) + mcp.tool(title="Get Pane Info", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( + get_pane_info + ) + mcp.tool(title="Clear Pane", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING})( + clear_pane + ) + mcp.tool(title="Search Panes", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( + search_panes + ) diff --git a/src/libtmux/mcp/tools/server_tools.py b/src/libtmux/mcp/tools/server_tools.py index 1097dea3f..39bb2ec29 100644 --- a/src/libtmux/mcp/tools/server_tools.py +++ b/src/libtmux/mcp/tools/server_tools.py @@ -5,6 +5,12 @@ import typing as t from libtmux.mcp._utils import ( + ANNOTATIONS_CREATE, + ANNOTATIONS_DESTRUCTIVE, + ANNOTATIONS_RO, + TAG_DESTRUCTIVE, + TAG_MUTATING, + TAG_READONLY, _apply_filters, _get_server, _invalidate_server, @@ -147,29 +153,15 @@ def get_server_info(socket_name: str | None = None) -> ServerInfo: def register(mcp: FastMCP) -> None: """Register server-level tools with the MCP instance.""" - _RO = { - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, - } - mcp.tool(title="List Sessions", annotations=_RO)(list_sessions) + mcp.tool(title="List Sessions", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( + list_sessions + ) mcp.tool( - title="Create Session", - annotations={ - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": False, - "openWorldHint": False, - }, + title="Create Session", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING} )(create_session) mcp.tool( - title="Kill Server", - annotations={ - "readOnlyHint": False, - "destructiveHint": True, - "idempotentHint": True, - "openWorldHint": False, - }, + title="Kill Server", annotations=ANNOTATIONS_DESTRUCTIVE, tags={TAG_DESTRUCTIVE} )(kill_server) - mcp.tool(title="Get Server Info", annotations=_RO)(get_server_info) + mcp.tool(title="Get Server Info", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( + get_server_info + ) diff --git a/src/libtmux/mcp/tools/session_tools.py b/src/libtmux/mcp/tools/session_tools.py index c45d764e5..77bee6de9 100644 --- a/src/libtmux/mcp/tools/session_tools.py +++ b/src/libtmux/mcp/tools/session_tools.py @@ -6,6 +6,13 @@ from libtmux.constants import WindowDirection from libtmux.mcp._utils import ( + ANNOTATIONS_CREATE, + ANNOTATIONS_DESTRUCTIVE, + ANNOTATIONS_MUTATING, + ANNOTATIONS_RO, + TAG_DESTRUCTIVE, + TAG_MUTATING, + TAG_READONLY, _apply_filters, _get_server, _resolve_session, @@ -181,35 +188,17 @@ def kill_session( def register(mcp: FastMCP) -> None: """Register session-level tools with the MCP instance.""" - _RO = { - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, - } - _IDEM = { - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, - } - mcp.tool(title="List Windows", annotations=_RO)(list_windows) + mcp.tool(title="List Windows", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( + list_windows + ) mcp.tool( - title="Create Window", - annotations={ - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": False, - "openWorldHint": False, - }, + title="Create Window", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING} )(create_window) - mcp.tool(title="Rename Session", annotations=_IDEM)(rename_session) + mcp.tool( + title="Rename Session", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} + )(rename_session) mcp.tool( title="Kill Session", - annotations={ - "readOnlyHint": False, - "destructiveHint": True, - "idempotentHint": True, - "openWorldHint": False, - }, + annotations=ANNOTATIONS_DESTRUCTIVE, + tags={TAG_DESTRUCTIVE}, )(kill_session) diff --git a/src/libtmux/mcp/tools/window_tools.py b/src/libtmux/mcp/tools/window_tools.py index 216c95ffe..8a7a1f917 100644 --- a/src/libtmux/mcp/tools/window_tools.py +++ b/src/libtmux/mcp/tools/window_tools.py @@ -6,6 +6,13 @@ from libtmux.constants import PaneDirection from libtmux.mcp._utils import ( + ANNOTATIONS_CREATE, + ANNOTATIONS_DESTRUCTIVE, + ANNOTATIONS_MUTATING, + ANNOTATIONS_RO, + TAG_DESTRUCTIVE, + TAG_MUTATING, + TAG_READONLY, _apply_filters, _get_server, _resolve_pane, @@ -346,37 +353,23 @@ def resize_window( def register(mcp: FastMCP) -> None: """Register window-level tools with the MCP instance.""" - _RO = { - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, - } - _IDEM = { - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, - } - mcp.tool(title="List Panes", annotations=_RO)(list_panes) + mcp.tool(title="List Panes", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( + list_panes + ) + mcp.tool(title="Split Window", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING})( + split_window + ) mcp.tool( - title="Split Window", - annotations={ - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": False, - "openWorldHint": False, - }, - )(split_window) - mcp.tool(title="Rename Window", annotations=_IDEM)(rename_window) + title="Rename Window", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} + )(rename_window) mcp.tool( title="Kill Window", - annotations={ - "readOnlyHint": False, - "destructiveHint": True, - "idempotentHint": True, - "openWorldHint": False, - }, + annotations=ANNOTATIONS_DESTRUCTIVE, + tags={TAG_DESTRUCTIVE}, )(kill_window) - mcp.tool(title="Select Layout", annotations=_IDEM)(select_layout) - mcp.tool(title="Resize Window", annotations=_IDEM)(resize_window) + mcp.tool( + title="Select Layout", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} + )(select_layout) + mcp.tool( + title="Resize Window", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} + )(resize_window) diff --git a/tests/mcp/test_utils.py b/tests/mcp/test_utils.py index 775c357af..415757146 100644 --- a/tests/mcp/test_utils.py +++ b/tests/mcp/test_utils.py @@ -9,6 +9,14 @@ from libtmux import exc from libtmux.mcp._utils import ( + ANNOTATIONS_CREATE, + ANNOTATIONS_DESTRUCTIVE, + ANNOTATIONS_MUTATING, + ANNOTATIONS_RO, + TAG_DESTRUCTIVE, + TAG_MUTATING, + TAG_READONLY, + VALID_SAFETY_LEVELS, _apply_filters, _get_caller_pane_id, _get_server, @@ -367,3 +375,49 @@ def test_serialize_pane_is_caller( data = _serialize_pane(mcp_pane) assert data.is_caller is expected_is_caller + + +# --------------------------------------------------------------------------- +# Annotation and tag constants tests +# --------------------------------------------------------------------------- + +_ANNOTATION_KEYS = { + "readOnlyHint", + "destructiveHint", + "idempotentHint", + "openWorldHint", +} + + +def test_annotation_presets_have_correct_keys() -> None: + """All annotation presets contain exactly the four MCP annotation keys.""" + for preset in ( + ANNOTATIONS_RO, + ANNOTATIONS_MUTATING, + ANNOTATIONS_CREATE, + ANNOTATIONS_DESTRUCTIVE, + ): + assert set(preset.keys()) == _ANNOTATION_KEYS + + +def test_annotations_ro_is_readonly() -> None: + """ANNOTATIONS_RO marks tools as read-only.""" + assert ANNOTATIONS_RO["readOnlyHint"] is True + assert ANNOTATIONS_RO["destructiveHint"] is False + + +def test_annotations_destructive_is_destructive() -> None: + """ANNOTATIONS_DESTRUCTIVE marks tools as destructive.""" + assert ANNOTATIONS_DESTRUCTIVE["destructiveHint"] is True + assert ANNOTATIONS_DESTRUCTIVE["readOnlyHint"] is False + + +def test_tag_constants() -> None: + """Safety tier tag constants are distinct strings.""" + tags = {TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE} + assert len(tags) == 3 + + +def test_valid_safety_levels_matches_tags() -> None: + """VALID_SAFETY_LEVELS contains all tag constants.""" + assert {TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE} == VALID_SAFETY_LEVELS From 9e9e561629740a63c73ef32c745a72dea7831ef6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 14:21:17 -0500 Subject: [PATCH 39/47] mcp(feat[middleware]): Add SafetyMiddleware to gate tools by tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The MCP server exposes `kill_server`, `kill_session`, `kill_window`, and `kill_pane` to any connected client with no safety guardrails beyond annotation hints. MCP clients SHOULD respect `destructiveHint`, but hints are advisory — the spec explicitly says they are untrusted. This middleware provides server-side defense in depth: tools tagged above the configured tier are hidden from `on_list_tools` AND blocked from `on_call_tool`. The double-gate prevents both discovery and execution. Configured via `LIBTMUX_SAFETY` env var: - `readonly`: only read operations - `mutating` (default): read + write + send_keys - `destructive`: all operations including kill_* Refs: - https://gofastmcp.com/servers/middleware - https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/ what: - Add SafetyMiddleware class in new middleware.py with _is_allowed(), on_list_tools(), and on_call_tool() hooks - Wire SafetyMiddleware into FastMCP instance in server.py, reading LIBTMUX_SAFETY env var with fallback to TAG_MUTATING - Add parametrized tests (10 tier combinations + default + fallback) --- src/libtmux/mcp/middleware.py | 68 +++++++++++++++++++ src/libtmux/mcp/server.py | 7 ++ tests/mcp/test_middleware.py | 119 ++++++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 src/libtmux/mcp/middleware.py create mode 100644 tests/mcp/test_middleware.py diff --git a/src/libtmux/mcp/middleware.py b/src/libtmux/mcp/middleware.py new file mode 100644 index 000000000..358bda641 --- /dev/null +++ b/src/libtmux/mcp/middleware.py @@ -0,0 +1,68 @@ +"""Safety middleware for libtmux MCP server. + +Gates tools by safety tier based on the ``LIBTMUX_SAFETY`` environment +variable. Tools tagged above the configured tier are hidden from listing +and blocked from execution. +""" + +from __future__ import annotations + +import typing as t + +from fastmcp.exceptions import ToolError +from fastmcp.server.middleware import Middleware, MiddlewareContext + +from libtmux.mcp._utils import TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY + +_TIER_LEVELS: dict[str, int] = { + TAG_READONLY: 0, + TAG_MUTATING: 1, + TAG_DESTRUCTIVE: 2, +} + + +class SafetyMiddleware(Middleware): + """Gate tools by safety tier. + + Parameters + ---------- + max_tier : str + Maximum allowed tier. One of ``TAG_READONLY``, ``TAG_MUTATING``, + or ``TAG_DESTRUCTIVE``. + """ + + def __init__(self, max_tier: str = TAG_MUTATING) -> None: + self.max_level = _TIER_LEVELS.get(max_tier, 1) + + def _is_allowed(self, tags: set[str]) -> bool: + """Return True if the tool's tags fall within the allowed tier.""" + for tier, level in _TIER_LEVELS.items(): + if tier in tags and level > self.max_level: + return False + return True + + async def on_list_tools( + self, + context: MiddlewareContext, + call_next: t.Any, + ) -> t.Any: + """Filter tools above the safety tier from the listing.""" + tools = await call_next(context) + return [tool for tool in tools if self._is_allowed(tool.tags)] + + async def on_call_tool( + self, + context: MiddlewareContext, + call_next: t.Any, + ) -> t.Any: + """Block execution of tools above the safety tier.""" + if context.fastmcp_context: + tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name) + if tool and not self._is_allowed(tool.tags): + msg = ( + f"Tool '{context.message.name}' is not available at the " + f"current safety level. Set LIBTMUX_SAFETY=destructive " + f"to enable destructive tools." + ) + raise ToolError(msg) + return await call_next(context) diff --git a/src/libtmux/mcp/server.py b/src/libtmux/mcp/server.py index 47877f12f..f64905dae 100644 --- a/src/libtmux/mcp/server.py +++ b/src/libtmux/mcp/server.py @@ -10,6 +10,8 @@ from fastmcp import FastMCP from libtmux.__about__ import __version__ +from libtmux.mcp._utils import TAG_MUTATING, VALID_SAFETY_LEVELS +from libtmux.mcp.middleware import SafetyMiddleware _BASE_INSTRUCTIONS = ( "libtmux MCP server for programmatic tmux control. " @@ -62,10 +64,15 @@ def _build_instructions() -> str: return _BASE_INSTRUCTIONS + context +_safety_level = os.environ.get("LIBTMUX_SAFETY", TAG_MUTATING) +if _safety_level not in VALID_SAFETY_LEVELS: + _safety_level = TAG_MUTATING + mcp = FastMCP( name="libtmux", version=__version__, instructions=_build_instructions(), + middleware=[SafetyMiddleware(max_tier=_safety_level)], ) diff --git a/tests/mcp/test_middleware.py b/tests/mcp/test_middleware.py new file mode 100644 index 000000000..70e01e227 --- /dev/null +++ b/tests/mcp/test_middleware.py @@ -0,0 +1,119 @@ +"""Tests for libtmux MCP safety middleware.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.mcp._utils import TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY +from libtmux.mcp.middleware import SafetyMiddleware + + +class SafetyAllowedFixture(t.NamedTuple): + """Test fixture for SafetyMiddleware._is_allowed.""" + + test_id: str + max_tier: str + tool_tags: set[str] + expected_allowed: bool + + +SAFETY_ALLOWED_FIXTURES: list[SafetyAllowedFixture] = [ + # readonly tier: only readonly tools allowed + SafetyAllowedFixture( + test_id="readonly_allows_readonly", + max_tier=TAG_READONLY, + tool_tags={TAG_READONLY}, + expected_allowed=True, + ), + SafetyAllowedFixture( + test_id="readonly_blocks_mutating", + max_tier=TAG_READONLY, + tool_tags={TAG_MUTATING}, + expected_allowed=False, + ), + SafetyAllowedFixture( + test_id="readonly_blocks_destructive", + max_tier=TAG_READONLY, + tool_tags={TAG_DESTRUCTIVE}, + expected_allowed=False, + ), + # mutating tier: readonly + mutating allowed + SafetyAllowedFixture( + test_id="mutating_allows_readonly", + max_tier=TAG_MUTATING, + tool_tags={TAG_READONLY}, + expected_allowed=True, + ), + SafetyAllowedFixture( + test_id="mutating_allows_mutating", + max_tier=TAG_MUTATING, + tool_tags={TAG_MUTATING}, + expected_allowed=True, + ), + SafetyAllowedFixture( + test_id="mutating_blocks_destructive", + max_tier=TAG_MUTATING, + tool_tags={TAG_DESTRUCTIVE}, + expected_allowed=False, + ), + # destructive tier: all allowed + SafetyAllowedFixture( + test_id="destructive_allows_readonly", + max_tier=TAG_DESTRUCTIVE, + tool_tags={TAG_READONLY}, + expected_allowed=True, + ), + SafetyAllowedFixture( + test_id="destructive_allows_mutating", + max_tier=TAG_DESTRUCTIVE, + tool_tags={TAG_MUTATING}, + expected_allowed=True, + ), + SafetyAllowedFixture( + test_id="destructive_allows_destructive", + max_tier=TAG_DESTRUCTIVE, + tool_tags={TAG_DESTRUCTIVE}, + expected_allowed=True, + ), + # untagged tools are always allowed + SafetyAllowedFixture( + test_id="untagged_allowed_at_readonly", + max_tier=TAG_READONLY, + tool_tags=set(), + expected_allowed=True, + ), +] + + +@pytest.mark.parametrize( + SafetyAllowedFixture._fields, + SAFETY_ALLOWED_FIXTURES, + ids=[f.test_id for f in SAFETY_ALLOWED_FIXTURES], +) +def test_safety_middleware_is_allowed( + test_id: str, + max_tier: str, + tool_tags: set[str], + expected_allowed: bool, +) -> None: + """SafetyMiddleware._is_allowed gates tools by tier.""" + mw = SafetyMiddleware(max_tier=max_tier) + assert mw._is_allowed(tool_tags) is expected_allowed + + +def test_safety_middleware_default_tier() -> None: + """SafetyMiddleware defaults to mutating tier.""" + mw = SafetyMiddleware() + assert mw._is_allowed({TAG_READONLY}) is True + assert mw._is_allowed({TAG_MUTATING}) is True + assert mw._is_allowed({TAG_DESTRUCTIVE}) is False + + +def test_safety_middleware_invalid_tier_falls_back() -> None: + """SafetyMiddleware falls back to mutating for unknown tiers.""" + mw = SafetyMiddleware(max_tier="nonexistent") + assert mw._is_allowed({TAG_READONLY}) is True + assert mw._is_allowed({TAG_MUTATING}) is True + assert mw._is_allowed({TAG_DESTRUCTIVE}) is False From 44b58fd6e4a528d5089d72e9f9ca4f57e485d622 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 14:22:58 -0500 Subject: [PATCH 40/47] mcp(feat[server]): Include safety level in server instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The LLM needs to know what tools are available at the current safety level. Without this, an agent at `readonly` level might attempt mutating operations and get confusing ToolError messages. Including the safety level in instructions lets the LLM self-regulate — it knows what tier it's operating at and what `LIBTMUX_SAFETY` values are available. what: - Refactor _build_instructions() to accept safety_level parameter - Always include "Safety level: {level}" section with tier descriptions - Update parametrized test fixtures to cover readonly/destructive levels - Add test asserting safety level text is always present in instructions --- src/libtmux/mcp/server.py | 62 +++++++++++++++++++++++++-------------- tests/mcp/test_server.py | 45 ++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 24 deletions(-) diff --git a/src/libtmux/mcp/server.py b/src/libtmux/mcp/server.py index f64905dae..59c9ea0c0 100644 --- a/src/libtmux/mcp/server.py +++ b/src/libtmux/mcp/server.py @@ -30,38 +30,56 @@ ) -def _build_instructions() -> str: - """Build server instructions, appending agent context if inside tmux. +def _build_instructions(safety_level: str = TAG_MUTATING) -> str: + """Build server instructions with agent context and safety level. When the MCP server process runs inside a tmux pane, ``TMUX_PANE`` and ``TMUX`` environment variables are available. This function appends that context so the LLM knows which pane is its own without extra tool calls. + Parameters + ---------- + safety_level : str + Active safety tier (readonly, mutating, or destructive). + Returns ------- str Server instructions string, optionally with agent tmux context. """ - tmux_pane = os.environ.get("TMUX_PANE") - if not tmux_pane: - return _BASE_INSTRUCTIONS - - # Parse TMUX env: "/tmp/tmux-1000/default,48188,10" - tmux_env = os.environ.get("TMUX", "") - parts = tmux_env.split(",") if tmux_env else [] - socket_path = parts[0] if parts else None - socket_name = socket_path.rsplit("/", 1)[-1] if socket_path else None - - context = ( - f"\n\nAgent context: This MCP server is running inside tmux pane {tmux_pane}" + parts: list[str] = [_BASE_INSTRUCTIONS] + + # Safety tier context + parts.append( + f"\n\nSafety level: {safety_level}. " + "Available tiers: 'readonly' (read operations only), " + "'mutating' (default, read + write + send_keys), " + "'destructive' (all operations including kill commands). " + "Set via LIBTMUX_SAFETY env var." ) - if socket_name: - context += f" (socket: {socket_name})" - context += ( - ". Tool results annotate the caller's own pane with " - "is_caller=true. Use this to distinguish your own pane from others." - ) - return _BASE_INSTRUCTIONS + context + + # Agent tmux context + tmux_pane = os.environ.get("TMUX_PANE") + if tmux_pane: + # Parse TMUX env: "/tmp/tmux-1000/default,48188,10" + tmux_env = os.environ.get("TMUX", "") + env_parts = tmux_env.split(",") if tmux_env else [] + socket_path = env_parts[0] if env_parts else None + socket_name = socket_path.rsplit("/", 1)[-1] if socket_path else None + + context = ( + f"\n\nAgent context: This MCP server is running inside " + f"tmux pane {tmux_pane}" + ) + if socket_name: + context += f" (socket: {socket_name})" + context += ( + ". Tool results annotate the caller's own pane with " + "is_caller=true. Use this to distinguish your own pane from others." + ) + parts.append(context) + + return "".join(parts) _safety_level = os.environ.get("LIBTMUX_SAFETY", TAG_MUTATING) @@ -71,7 +89,7 @@ def _build_instructions() -> str: mcp = FastMCP( name="libtmux", version=__version__, - instructions=_build_instructions(), + instructions=_build_instructions(safety_level=_safety_level), middleware=[SafetyMiddleware(max_tier=_safety_level)], ) diff --git a/tests/mcp/test_server.py b/tests/mcp/test_server.py index 95f6fd243..a80fc56d5 100644 --- a/tests/mcp/test_server.py +++ b/tests/mcp/test_server.py @@ -6,6 +6,7 @@ import pytest +from libtmux.mcp._utils import TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY from libtmux.mcp.server import _BASE_INSTRUCTIONS, _build_instructions @@ -13,37 +14,65 @@ class BuildInstructionsFixture(t.NamedTuple): """Test fixture for _build_instructions.""" test_id: str + safety_level: str tmux_pane_env: str | None tmux_env: str | None expect_agent_context: bool expect_pane_id_in_text: str | None expect_socket_name: str | None + expect_safety_in_text: str | None BUILD_INSTRUCTIONS_FIXTURES: list[BuildInstructionsFixture] = [ BuildInstructionsFixture( test_id="inside_tmux_full_context", + safety_level=TAG_MUTATING, tmux_pane_env="%42", tmux_env="/tmp/tmux-1000/default,12345,0", expect_agent_context=True, expect_pane_id_in_text="%42", expect_socket_name="default", + expect_safety_in_text="mutating", ), BuildInstructionsFixture( test_id="outside_tmux_no_context", + safety_level=TAG_MUTATING, tmux_pane_env=None, tmux_env=None, expect_agent_context=False, expect_pane_id_in_text=None, expect_socket_name=None, + expect_safety_in_text="mutating", ), BuildInstructionsFixture( test_id="pane_only_no_tmux_env", + safety_level=TAG_MUTATING, tmux_pane_env="%99", tmux_env=None, expect_agent_context=True, expect_pane_id_in_text="%99", expect_socket_name=None, + expect_safety_in_text="mutating", + ), + BuildInstructionsFixture( + test_id="readonly_safety_level", + safety_level=TAG_READONLY, + tmux_pane_env=None, + tmux_env=None, + expect_agent_context=False, + expect_pane_id_in_text=None, + expect_socket_name=None, + expect_safety_in_text="readonly", + ), + BuildInstructionsFixture( + test_id="destructive_safety_level", + safety_level=TAG_DESTRUCTIVE, + tmux_pane_env=None, + tmux_env=None, + expect_agent_context=False, + expect_pane_id_in_text=None, + expect_socket_name=None, + expect_safety_in_text="destructive", ), ] @@ -56,13 +85,15 @@ class BuildInstructionsFixture(t.NamedTuple): def test_build_instructions( monkeypatch: pytest.MonkeyPatch, test_id: str, + safety_level: str, tmux_pane_env: str | None, tmux_env: str | None, expect_agent_context: bool, expect_pane_id_in_text: str | None, expect_socket_name: str | None, + expect_safety_in_text: str | None, ) -> None: - """_build_instructions includes agent context when inside tmux.""" + """_build_instructions includes agent context and safety level.""" if tmux_pane_env is not None: monkeypatch.setenv("TMUX_PANE", tmux_pane_env) else: @@ -73,7 +104,7 @@ def test_build_instructions( else: monkeypatch.delenv("TMUX", raising=False) - result = _build_instructions() + result = _build_instructions(safety_level=safety_level) # Base instructions are always present assert _BASE_INSTRUCTIONS in result @@ -89,6 +120,9 @@ def test_build_instructions( if expect_socket_name is not None: assert expect_socket_name in result + if expect_safety_in_text is not None: + assert f"Safety level: {expect_safety_in_text}" in result + def test_base_instructions_content() -> None: """_BASE_INSTRUCTIONS contains key guidance for the LLM.""" @@ -96,3 +130,10 @@ def test_base_instructions_content() -> None: assert "pane_id" in _BASE_INSTRUCTIONS assert "search_panes" in _BASE_INSTRUCTIONS assert "metadata vs content" in _BASE_INSTRUCTIONS + + +def test_build_instructions_always_includes_safety() -> None: + """_build_instructions always includes the safety level.""" + result = _build_instructions(safety_level=TAG_MUTATING) + assert "Safety level:" in result + assert "LIBTMUX_SAFETY" in result From b4b82311a69c4e18dbaf80378a357ede19f8d6ef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 15:34:30 -0500 Subject: [PATCH 41/47] mcp(fix[tools]): Require explicit target for destructive tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The resolver chain (`_resolve_session`, `_resolve_window`, `_resolve_pane`) falls back to `items[0]` when no identifier is provided. For destructive tools, this means `kill_session()` with no args silently kills whichever session happens to be first — and calling it twice kills two different sessions. This is dangerous for an AI-facing control plane where the LLM might omit a target parameter. The `idempotentHint: True` on `ANNOTATIONS_DESTRUCTIVE` compounded the risk — MCP clients that trust the hint might auto-retry on failure, escalating destruction. what: - Add explicit target guards to kill_session, kill_window, kill_pane that raise ToolError when no targeting parameter is provided - Fix ANNOTATIONS_DESTRUCTIVE idempotentHint from True to False - Add test_kill_*_requires_target tests for all three tools --- src/libtmux/mcp/_utils.py | 2 +- src/libtmux/mcp/tools/pane_tools.py | 9 +++++++++ src/libtmux/mcp/tools/session_tools.py | 9 +++++++++ src/libtmux/mcp/tools/window_tools.py | 9 +++++++++ tests/mcp/test_pane_tools.py | 6 ++++++ tests/mcp/test_session_tools.py | 6 ++++++ tests/mcp/test_window_tools.py | 6 ++++++ 7 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/libtmux/mcp/_utils.py b/src/libtmux/mcp/_utils.py index 084bf03f3..a9130ca39 100644 --- a/src/libtmux/mcp/_utils.py +++ b/src/libtmux/mcp/_utils.py @@ -66,7 +66,7 @@ def _get_caller_pane_id() -> str | None: ANNOTATIONS_DESTRUCTIVE: dict[str, bool] = { "readOnlyHint": False, "destructiveHint": True, - "idempotentHint": True, + "idempotentHint": False, "openWorldHint": False, } diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index 2a82c4dfa..c00a35710 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -226,6 +226,15 @@ def kill_pane( str Confirmation message. """ + from fastmcp.exceptions import ToolError + + if all(v is None for v in (pane_id, session_name, session_id, window_id)): + msg = ( + "Refusing to kill without an explicit target. " + "Provide pane_id, or a window/session identifier." + ) + raise ToolError(msg) + server = _get_server(socket_name=socket_name) pane = _resolve_pane( server, diff --git a/src/libtmux/mcp/tools/session_tools.py b/src/libtmux/mcp/tools/session_tools.py index 77bee6de9..e7ed66ac7 100644 --- a/src/libtmux/mcp/tools/session_tools.py +++ b/src/libtmux/mcp/tools/session_tools.py @@ -179,6 +179,15 @@ def kill_session( str Confirmation message. """ + from fastmcp.exceptions import ToolError + + if session_name is None and session_id is None: + msg = ( + "Refusing to kill without an explicit target. " + "Provide session_name or session_id." + ) + raise ToolError(msg) + server = _get_server(socket_name=socket_name) session = _resolve_session(server, session_name=session_name, session_id=session_id) name = session.session_name or session.session_id diff --git a/src/libtmux/mcp/tools/window_tools.py b/src/libtmux/mcp/tools/window_tools.py index 8a7a1f917..48474db2f 100644 --- a/src/libtmux/mcp/tools/window_tools.py +++ b/src/libtmux/mcp/tools/window_tools.py @@ -246,6 +246,15 @@ def kill_window( str Confirmation message. """ + from fastmcp.exceptions import ToolError + + if all(v is None for v in (window_id, window_index, session_name, session_id)): + msg = ( + "Refusing to kill without an explicit target. " + "Provide window_id, or window_index with a session identifier." + ) + raise ToolError(msg) + server = _get_server(socket_name=socket_name) window = _resolve_window( server, diff --git a/tests/mcp/test_pane_tools.py b/tests/mcp/test_pane_tools.py index 33d3ec115..adb62c10c 100644 --- a/tests/mcp/test_pane_tools.py +++ b/tests/mcp/test_pane_tools.py @@ -113,6 +113,12 @@ def test_resize_pane_zoom_mutual_exclusivity( ) +def test_kill_pane_requires_target(mcp_server: Server) -> None: + """kill_pane refuses to kill without an explicit target.""" + with pytest.raises(ToolError, match="Refusing to kill"): + kill_pane(socket_name=mcp_server.socket_name) + + def test_kill_pane(mcp_server: Server, mcp_session: Session) -> None: """kill_pane kills a pane.""" window = mcp_session.active_window diff --git a/tests/mcp/test_session_tools.py b/tests/mcp/test_session_tools.py index f44030b77..1fa3202e8 100644 --- a/tests/mcp/test_session_tools.py +++ b/tests/mcp/test_session_tools.py @@ -192,6 +192,12 @@ def test_list_windows_with_filters( second_session.kill() +def test_kill_session_requires_target(mcp_server: Server) -> None: + """kill_session refuses to kill without an explicit target.""" + with pytest.raises(ToolError, match="Refusing to kill"): + kill_session(socket_name=mcp_server.socket_name) + + def test_kill_session(mcp_server: Server) -> None: """kill_session kills a session.""" mcp_server.new_session(session_name="mcp_kill_me") diff --git a/tests/mcp/test_window_tools.py b/tests/mcp/test_window_tools.py index a84c5411c..7539403ba 100644 --- a/tests/mcp/test_window_tools.py +++ b/tests/mcp/test_window_tools.py @@ -202,6 +202,12 @@ def test_list_panes_with_filters( assert len(result) >= expected_min_count +def test_kill_window_requires_target(mcp_server: Server) -> None: + """kill_window refuses to kill without an explicit target.""" + with pytest.raises(ToolError, match="Refusing to kill"): + kill_window(socket_name=mcp_server.socket_name) + + def test_kill_window(mcp_server: Server, mcp_session: Session) -> None: """kill_window kills a window.""" new_window = mcp_session.new_window(window_name="mcp_kill_win") From 53650e6cd14d092f9efc7430368174d2819e683a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 15:35:24 -0500 Subject: [PATCH 42/47] mcp(fix[pane_tools]): Make search_panes result ordering deterministic why: `matching_pane_ids` was collected into a `set()`, which has nondeterministic iteration order in Python. This caused search_panes results to appear in different order across calls for the same query, making agent behavior unpredictable and tests fragile. what: - Replace set() with list(dict.fromkeys(...)) for order-preserving dedup in both the tmux fast path and Python fallback path - Sort final matches by pane_id for fully deterministic output --- src/libtmux/mcp/tools/pane_tools.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index c00a35710..bf7c91149 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -448,7 +448,7 @@ def search_panes( cmd_args.extend(["-f", tmux_filter, "-F", "#{pane_id}"]) result = server.cmd(*cmd_args) - matching_pane_ids = set(result.stdout) if result.stdout else set() + matching_pane_ids = list(dict.fromkeys(result.stdout)) if result.stdout else [] else: # Regex pattern or scrollback requested — fall back to capturing # all panes and matching in Python. @@ -459,7 +459,9 @@ def search_panes( all_panes = session.panes else: all_panes = server.panes - matching_pane_ids = {p.pane_id for p in all_panes if p.pane_id is not None} + matching_pane_ids = list( + dict.fromkeys(p.pane_id for p in all_panes if p.pane_id is not None) + ) # Phase 2: Capture matching panes and extract matched lines. caller_pane_id = _get_caller_pane_id() @@ -491,6 +493,7 @@ def search_panes( ) ) + matches.sort(key=lambda m: m.pane_id) return matches From 1b643e0f6a7c1a37a3c20f98efe6dc8058f6aedf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 16:00:22 -0500 Subject: [PATCH 43/47] fix(Pane[reset]): Split reset into separate send-keys and clear-history commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: `pane.reset()` sent `send-keys -R \; clear-history` as a single `cmd()` call. Since `subprocess.Popen` is called without `shell=True`, the `\;` is never interpreted as a tmux command separator — it's passed as a literal argument to `send-keys`. This means `clear-history` never executes, and scrollback is never cleared. Refs: - https://github.com/tmux-python/libtmux/issues/650 what: - Split into two separate cmd() calls: send-keys -R then clear-history - Strengthen test_clear_pane to verify marker text disappears from scrollback after clearing Closes #650 --- src/libtmux/pane.py | 3 ++- tests/mcp/test_pane_tools.py | 14 +++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 610ba3d2b..3043a17ea 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -873,7 +873,8 @@ def clear(self) -> Pane: def reset(self) -> Pane: """Reset and clear pane history.""" - self.cmd("send-keys", r"-R \; clear-history") + self.cmd("send-keys", "-R") + self.cmd("clear-history") return self # diff --git a/tests/mcp/test_pane_tools.py b/tests/mcp/test_pane_tools.py index adb62c10c..65a210bd9 100644 --- a/tests/mcp/test_pane_tools.py +++ b/tests/mcp/test_pane_tools.py @@ -67,13 +67,25 @@ def test_set_pane_title(mcp_server: Server, mcp_pane: Pane) -> None: def test_clear_pane(mcp_server: Server, mcp_pane: Pane) -> None: - """clear_pane clears pane content.""" + """clear_pane resets terminal and clears scrollback history.""" + marker = "CLEAR_PANE_MARKER_xyz789" + mcp_pane.send_keys(f"echo {marker}", enter=True) + retry_until( + lambda: marker in "\n".join(mcp_pane.capture_pane()), + 2, + raises=True, + ) + result = clear_pane( pane_id=mcp_pane.pane_id, socket_name=mcp_server.socket_name, ) assert "cleared" in result.lower() + # After reset + clear-history, the marker should be gone from scrollback + scrollback = "\n".join(mcp_pane.capture_pane(start=-200, end=-1)) + assert marker not in scrollback + def test_resize_pane_dimensions(mcp_server: Server, mcp_pane: Pane) -> None: """resize_pane resizes a pane with height/width.""" From b47634b4754303cc9b0d490c2944c418ed1c63ad Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 16:01:38 -0500 Subject: [PATCH 44/47] mcp(feat[resources]): Add socket_name query param to all resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: All 6 resource handlers called `_get_server()` with no arguments, while every tool function accepts `socket_name`. The server instructions promise multi-server support via `socket_name`, but resources were limited to the default socket — a capability gap. FastMCP supports RFC 6570 query parameters via `{?param}` syntax in URI templates. Query parameters must be optional with default values. Refs: - https://github.com/tmux-python/libtmux/issues/647 what: - Add `{?socket_name}` to all 6 resource URI templates - Add `socket_name: str | None = None` parameter to all resource handlers - Pass socket_name through to `_get_server()` calls - Update test URI template keys to match new templates Closes #647 --- src/libtmux/mcp/resources/hierarchy.py | 70 +++++++++++++++++++------- tests/mcp/test_resources.py | 14 +++--- 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/libtmux/mcp/resources/hierarchy.py b/src/libtmux/mcp/resources/hierarchy.py index cdddb19c7..cd726027e 100644 --- a/src/libtmux/mcp/resources/hierarchy.py +++ b/src/libtmux/mcp/resources/hierarchy.py @@ -21,34 +21,47 @@ def register(mcp: FastMCP) -> None: """Register hierarchy resources with the FastMCP instance.""" - @mcp.resource("tmux://sessions", title="All Sessions") - def get_sessions() -> str: + @mcp.resource("tmux://sessions{?socket_name}", title="All Sessions") + def get_sessions(socket_name: str | None = None) -> str: """List all tmux sessions. + Parameters + ---------- + socket_name : str, optional + tmux socket name. Defaults to LIBTMUX_SOCKET env var. + Returns ------- str JSON array of session objects. """ - server = _get_server() + server = _get_server(socket_name=socket_name) sessions = [_serialize_session(s).model_dump() for s in server.sessions] return json.dumps(sessions, indent=2) - @mcp.resource("tmux://sessions/{session_name}", title="Session Detail") - def get_session(session_name: str) -> str: + @mcp.resource( + "tmux://sessions/{session_name}{?socket_name}", + title="Session Detail", + ) + def get_session( + session_name: str, + socket_name: str | None = None, + ) -> str: """Get details of a specific tmux session. Parameters ---------- session_name : str The session name. + socket_name : str, optional + tmux socket name. Defaults to LIBTMUX_SOCKET env var. Returns ------- str JSON object with session info and its windows. """ - server = _get_server() + server = _get_server(socket_name=socket_name) session = server.sessions.get(session_name=session_name, default=None) if session is None: msg = f"Session not found: {session_name}" @@ -58,21 +71,29 @@ def get_session(session_name: str) -> str: result["windows"] = [_serialize_window(w).model_dump() for w in session.windows] return json.dumps(result, indent=2) - @mcp.resource("tmux://sessions/{session_name}/windows", title="Session Windows") - def get_session_windows(session_name: str) -> str: + @mcp.resource( + "tmux://sessions/{session_name}/windows{?socket_name}", + title="Session Windows", + ) + def get_session_windows( + session_name: str, + socket_name: str | None = None, + ) -> str: """List all windows in a tmux session. Parameters ---------- session_name : str The session name. + socket_name : str, optional + tmux socket name. Defaults to LIBTMUX_SOCKET env var. Returns ------- str JSON array of window objects. """ - server = _get_server() + server = _get_server(socket_name=socket_name) session = server.sessions.get(session_name=session_name, default=None) if session is None: msg = f"Session not found: {session_name}" @@ -82,10 +103,14 @@ def get_session_windows(session_name: str) -> str: return json.dumps(windows, indent=2) @mcp.resource( - "tmux://sessions/{session_name}/windows/{window_index}", + "tmux://sessions/{session_name}/windows/{window_index}{?socket_name}", title="Window Detail", ) - def get_window(session_name: str, window_index: str) -> str: + def get_window( + session_name: str, + window_index: str, + socket_name: str | None = None, + ) -> str: """Get details of a specific window in a session. Parameters @@ -94,13 +119,15 @@ def get_window(session_name: str, window_index: str) -> str: The session name. window_index : str The window index within the session. + socket_name : str, optional + tmux socket name. Defaults to LIBTMUX_SOCKET env var. Returns ------- str JSON object with window info and its panes. """ - server = _get_server() + server = _get_server(socket_name=socket_name) session = server.sessions.get(session_name=session_name, default=None) if session is None: msg = f"Session not found: {session_name}" @@ -115,21 +142,23 @@ def get_window(session_name: str, window_index: str) -> str: result["panes"] = [_serialize_pane(p).model_dump() for p in window.panes] return json.dumps(result, indent=2) - @mcp.resource("tmux://panes/{pane_id}", title="Pane Detail") - def get_pane(pane_id: str) -> str: + @mcp.resource("tmux://panes/{pane_id}{?socket_name}", title="Pane Detail") + def get_pane(pane_id: str, socket_name: str | None = None) -> str: """Get details of a specific pane. Parameters ---------- pane_id : str The pane ID (e.g. '%1'). + socket_name : str, optional + tmux socket name. Defaults to LIBTMUX_SOCKET env var. Returns ------- str JSON object of pane details. """ - server = _get_server() + server = _get_server(socket_name=socket_name) pane = server.panes.get(pane_id=pane_id, default=None) if pane is None: msg = f"Pane not found: {pane_id}" @@ -137,21 +166,26 @@ def get_pane(pane_id: str) -> str: return json.dumps(_serialize_pane(pane).model_dump(), indent=2) - @mcp.resource("tmux://panes/{pane_id}/content", title="Pane Content") - def get_pane_content(pane_id: str) -> str: + @mcp.resource( + "tmux://panes/{pane_id}/content{?socket_name}", + title="Pane Content", + ) + def get_pane_content(pane_id: str, socket_name: str | None = None) -> str: """Capture and return the content of a pane. Parameters ---------- pane_id : str The pane ID (e.g. '%1'). + socket_name : str, optional + tmux socket name. Defaults to LIBTMUX_SOCKET env var. Returns ------- str Plain text captured pane content. """ - server = _get_server() + server = _get_server(socket_name=socket_name) pane = server.panes.get(pane_id=pane_id, default=None) if pane is None: msg = f"Pane not found: {pane_id}" diff --git a/tests/mcp/test_resources.py b/tests/mcp/test_resources.py index 23d573507..a26ed3c3d 100644 --- a/tests/mcp/test_resources.py +++ b/tests/mcp/test_resources.py @@ -41,7 +41,7 @@ def test_sessions_resource( resource_functions: dict[str, t.Any], mcp_session: Session ) -> None: """tmux://sessions returns session list.""" - fn = resource_functions["tmux://sessions"] + fn = resource_functions["tmux://sessions{?socket_name}"] result = fn() data = json.loads(result) assert isinstance(data, list) @@ -52,7 +52,7 @@ def test_session_detail_resource( resource_functions: dict[str, t.Any], mcp_session: Session ) -> None: """tmux://sessions/{name} returns session with windows.""" - fn = resource_functions["tmux://sessions/{session_name}"] + fn = resource_functions["tmux://sessions/{session_name}{?socket_name}"] result = fn(mcp_session.session_name) data = json.loads(result) assert "session_id" in data @@ -63,7 +63,7 @@ def test_session_windows_resource( resource_functions: dict[str, t.Any], mcp_session: Session ) -> None: """tmux://sessions/{name}/windows returns window list.""" - fn = resource_functions["tmux://sessions/{session_name}/windows"] + fn = resource_functions["tmux://sessions/{session_name}/windows{?socket_name}"] result = fn(mcp_session.session_name) data = json.loads(result) assert isinstance(data, list) @@ -75,7 +75,9 @@ def test_window_detail_resource( mcp_window: Window, ) -> None: """tmux://sessions/{name}/windows/{index} returns window with panes.""" - fn = resource_functions["tmux://sessions/{session_name}/windows/{window_index}"] + fn = resource_functions[ + "tmux://sessions/{session_name}/windows/{window_index}{?socket_name}" + ] result = fn(mcp_session.session_name, mcp_window.window_index) data = json.loads(result) assert "window_id" in data @@ -86,7 +88,7 @@ def test_pane_detail_resource( resource_functions: dict[str, t.Any], mcp_pane: Pane ) -> None: """tmux://panes/{pane_id} returns pane details.""" - fn = resource_functions["tmux://panes/{pane_id}"] + fn = resource_functions["tmux://panes/{pane_id}{?socket_name}"] result = fn(mcp_pane.pane_id) data = json.loads(result) assert data["pane_id"] == mcp_pane.pane_id @@ -96,6 +98,6 @@ def test_pane_content_resource( resource_functions: dict[str, t.Any], mcp_pane: Pane ) -> None: """tmux://panes/{pane_id}/content returns captured text.""" - fn = resource_functions["tmux://panes/{pane_id}/content"] + fn = resource_functions["tmux://panes/{pane_id}/content{?socket_name}"] result = fn(mcp_pane.pane_id) assert isinstance(result, str) From 35e43eb6a9c6f82fecd31719ed43051cd4749435 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 16:04:10 -0500 Subject: [PATCH 45/47] mcp(test[resources]): Add integration tests via FastMCP Client why: Existing resource tests used a MockMCP class that called handler functions directly, never exercising FastMCP's URI routing, parameter extraction, or MCP protocol handling. This meant transport-level bugs (URI encoding, template matching, response formatting) couldn't be caught. FastMCP's `Client(mcp)` enables in-process testing against the real MCP protocol stack without network transport. Refs: - https://github.com/tmux-python/libtmux/issues/649 what: - Add test_resources_integration.py with parametrized ResourceIntegrationFixture - 5 test cases covering sessions list, session detail, session windows, pane detail, and pane content via real Client(mcp).read_resource() calls - Uses asyncio.run() wrapper to keep tests synchronous (no pytest-asyncio dep) Closes #649 --- tests/mcp/test_resources_integration.py | 119 ++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/mcp/test_resources_integration.py diff --git a/tests/mcp/test_resources_integration.py b/tests/mcp/test_resources_integration.py new file mode 100644 index 000000000..d426e0478 --- /dev/null +++ b/tests/mcp/test_resources_integration.py @@ -0,0 +1,119 @@ +"""Integration tests for libtmux MCP resources via FastMCP Client.""" + +from __future__ import annotations + +import asyncio +import json +import typing as t + +import pytest +from fastmcp import Client + +from libtmux.mcp.server import _register_all, mcp + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + from libtmux.window import Window + + +_registered = False + + +@pytest.fixture(autouse=True) +def _ensure_registered() -> None: + """Ensure tools and resources are registered with the MCP server once.""" + global _registered + if not _registered: + _register_all() + _registered = True + + +def _run(coro: t.Any) -> t.Any: + """Run an async coroutine synchronously.""" + return asyncio.run(coro) + + +async def _read(uri: str) -> str: + """Read a resource via FastMCP Client and return text content.""" + async with Client(mcp) as client: + results = await client.read_resource(uri) + assert len(results) >= 1 + return results[0].text or "" + + +class ResourceIntegrationFixture(t.NamedTuple): + """Test fixture for resource integration reads.""" + + test_id: str + uri_template: str + expect_json: bool + expect_contains: str | None + + +RESOURCE_INTEGRATION_FIXTURES: list[ResourceIntegrationFixture] = [ + ResourceIntegrationFixture( + test_id="list_all_sessions", + uri_template="tmux://sessions", + expect_json=True, + expect_contains="session_id", + ), + ResourceIntegrationFixture( + test_id="session_detail", + uri_template="tmux://sessions/{session_name}", + expect_json=True, + expect_contains="windows", + ), + ResourceIntegrationFixture( + test_id="session_windows", + uri_template="tmux://sessions/{session_name}/windows", + expect_json=True, + expect_contains="window_id", + ), + ResourceIntegrationFixture( + test_id="pane_detail", + uri_template="tmux://panes/{pane_id}", + expect_json=True, + expect_contains="pane_id", + ), + ResourceIntegrationFixture( + test_id="pane_content", + uri_template="tmux://panes/{pane_id}/content", + expect_json=False, + expect_contains=None, # fresh pane may be empty + ), +] + + +@pytest.mark.parametrize( + ResourceIntegrationFixture._fields, + RESOURCE_INTEGRATION_FIXTURES, + ids=[f.test_id for f in RESOURCE_INTEGRATION_FIXTURES], +) +def test_resource_read_via_client( + mcp_server: Server, + mcp_session: Session, + mcp_window: Window, + mcp_pane: Pane, + test_id: str, + uri_template: str, + expect_json: bool, + expect_contains: str | None, +) -> None: + """Resources are readable via FastMCP Client protocol.""" + uri = uri_template.format( + session_name=mcp_session.session_name, + window_index=mcp_window.window_index, + pane_id=mcp_pane.pane_id, + ) + + text = _run(_read(uri)) + assert isinstance(text, str) + + if expect_json: + data = json.loads(text) + assert data is not None + + if expect_contains is not None: + assert expect_contains in text From 16ac3bdebcf4219d0d8e0d56f5fd38d023513874 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 16:06:37 -0500 Subject: [PATCH 46/47] mcp(feat[pane_tools]): Add wait_for_text tool for terminal automation why: Terminal automation requires waiting for specific output to appear (e.g., build completion, prompt return). Agents currently must poll `capture_pane` repeatedly, consuming tokens and turns. A dedicated tool saves both by encapsulating the poll loop server-side. Uses libtmux's existing `retry_until` infrastructure (8s default timeout, 50ms poll interval) and the same regex pattern matching as `search_panes`. Refs: - https://github.com/tmux-python/libtmux/issues/651 what: - Add WaitForTextResult Pydantic model to models.py - Add wait_for_text() tool using retry_until internally - Returns structured result with found/timed_out/matched_lines/elapsed - Tagged TAG_READONLY with ANNOTATIONS_RO (read-only, idempotent) - Add parametrized WaitForTextFixture tests (found + timeout cases) - Add test_wait_for_text_invalid_regex for bad pattern handling Closes #651 --- src/libtmux/mcp/models.py | 13 ++++ src/libtmux/mcp/tools/pane_tools.py | 106 +++++++++++++++++++++++++++- tests/mcp/test_pane_tools.py | 80 ++++++++++++++++++++- 3 files changed, 197 insertions(+), 2 deletions(-) diff --git a/src/libtmux/mcp/models.py b/src/libtmux/mcp/models.py index e7f63b953..25579469c 100644 --- a/src/libtmux/mcp/models.py +++ b/src/libtmux/mcp/models.py @@ -120,3 +120,16 @@ class EnvironmentSetResult(BaseModel): name: str = Field(description="Variable name") value: str = Field(description="Value that was set") status: str = Field(description="Operation status") + + +class WaitForTextResult(BaseModel): + """Result of waiting for text to appear in a pane.""" + + found: bool = Field(description="Whether the pattern was found before timeout") + matched_lines: list[str] = Field( + default_factory=list, + description="Lines matching the pattern (empty if not found)", + ) + pane_id: str = Field(description="Pane ID that was polled") + elapsed_seconds: float = Field(description="Time spent waiting in seconds") + timed_out: bool = Field(description="Whether the timeout was reached") diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index bf7c91149..6b5039fba 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -20,7 +20,7 @@ _serialize_pane, handle_tool_errors, ) -from libtmux.mcp.models import PaneContentMatch, PaneInfo +from libtmux.mcp.models import PaneContentMatch, PaneInfo, WaitForTextResult if t.TYPE_CHECKING: from fastmcp import FastMCP @@ -497,6 +497,107 @@ def search_panes( return matches +@handle_tool_errors +def wait_for_text( + pattern: str, + pane_id: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + timeout: float = 8.0, + interval: float = 0.05, + match_case: bool = False, + content_start: int | None = None, + content_end: int | None = None, + socket_name: str | None = None, +) -> WaitForTextResult: + """Wait for text to appear in a tmux pane. + + Polls the pane content at regular intervals until the pattern is found + or the timeout is reached. Use this instead of polling capture_pane + manually — it saves agent tokens and turns. + + Parameters + ---------- + pattern : str + Text or regex pattern to wait for. + pane_id : str, optional + Pane ID (e.g. '%1'). + session_name : str, optional + Session name for pane resolution. + session_id : str, optional + Session ID (e.g. '$1') for pane resolution. + window_id : str, optional + Window ID for pane resolution. + timeout : float + Maximum seconds to wait. Default 8.0. + interval : float + Seconds between polls. Default 0.05 (50ms). + match_case : bool + Whether to match case. Default False (case-insensitive). + content_start : int, optional + Start line for capture. Negative values reach into scrollback. + content_end : int, optional + End line for capture. + socket_name : str, optional + tmux socket name. + + Returns + ------- + WaitForTextResult + Result with found status, matched lines, and timing info. + """ + import time + + from fastmcp.exceptions import ToolError + + from libtmux.test.retry import retry_until + + flags = 0 if match_case else re.IGNORECASE + try: + compiled = re.compile(pattern, flags) + except re.error as e: + msg = f"Invalid regex pattern: {e}" + raise ToolError(msg) from e + + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + session_id=session_id, + window_id=window_id, + ) + + assert pane.pane_id is not None + matched_lines: list[str] = [] + start_time = time.monotonic() + + def _check() -> bool: + lines = pane.capture_pane(start=content_start, end=content_end) + hits = [line for line in lines if compiled.search(line)] + if hits: + matched_lines.extend(hits) + return True + return False + + found = retry_until( + _check, + seconds=timeout, + interval=interval, + raises=False, + ) + + elapsed = time.monotonic() - start_time + return WaitForTextResult( + found=found, + matched_lines=matched_lines, + pane_id=pane.pane_id, + elapsed_seconds=round(elapsed, 3), + timed_out=not found, + ) + + def register(mcp: FastMCP) -> None: """Register pane-level tools with the MCP instance.""" mcp.tool(title="Send Keys", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING})( @@ -525,3 +626,6 @@ def register(mcp: FastMCP) -> None: mcp.tool(title="Search Panes", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( search_panes ) + mcp.tool(title="Wait For Text", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( + wait_for_text + ) diff --git a/tests/mcp/test_pane_tools.py b/tests/mcp/test_pane_tools.py index 65a210bd9..0315e0c03 100644 --- a/tests/mcp/test_pane_tools.py +++ b/tests/mcp/test_pane_tools.py @@ -7,7 +7,7 @@ import pytest from fastmcp.exceptions import ToolError -from libtmux.mcp.models import PaneContentMatch +from libtmux.mcp.models import PaneContentMatch, WaitForTextResult from libtmux.mcp.tools.pane_tools import ( capture_pane, clear_pane, @@ -17,6 +17,7 @@ search_panes, send_keys, set_pane_title, + wait_for_text, ) from libtmux.test.retry import retry_until @@ -413,3 +414,80 @@ def test_search_panes_is_caller( match = next((r for r in result if r.pane_id == mcp_pane.pane_id), None) assert match is not None assert match.is_caller is expected_is_caller + + +# --------------------------------------------------------------------------- +# wait_for_text tests +# --------------------------------------------------------------------------- + + +class WaitForTextFixture(t.NamedTuple): + """Test fixture for wait_for_text.""" + + test_id: str + command: str | None + pattern: str + timeout: float + expected_found: bool + + +WAIT_FOR_TEXT_FIXTURES: list[WaitForTextFixture] = [ + WaitForTextFixture( + test_id="text_found", + command="echo WAIT_MARKER_abc123", + pattern="WAIT_MARKER_abc123", + timeout=2.0, + expected_found=True, + ), + WaitForTextFixture( + test_id="timeout_not_found", + command=None, + pattern="NEVER_EXISTS_xyz999", + timeout=0.3, + expected_found=False, + ), +] + + +@pytest.mark.parametrize( + WaitForTextFixture._fields, + WAIT_FOR_TEXT_FIXTURES, + ids=[f.test_id for f in WAIT_FOR_TEXT_FIXTURES], +) +def test_wait_for_text( + mcp_server: Server, + mcp_pane: Pane, + test_id: str, + command: str | None, + pattern: str, + timeout: float, + expected_found: bool, +) -> None: + """wait_for_text polls pane content for a pattern.""" + if command is not None: + mcp_pane.send_keys(command, enter=True) + + result = wait_for_text( + pattern=pattern, + pane_id=mcp_pane.pane_id, + timeout=timeout, + socket_name=mcp_server.socket_name, + ) + assert isinstance(result, WaitForTextResult) + assert result.found is expected_found + assert result.timed_out is (not expected_found) + assert result.pane_id == mcp_pane.pane_id + assert result.elapsed_seconds >= 0 + + if expected_found: + assert len(result.matched_lines) >= 1 + + +def test_wait_for_text_invalid_regex(mcp_server: Server, mcp_pane: Pane) -> None: + """wait_for_text raises ToolError on invalid regex.""" + with pytest.raises(ToolError, match="Invalid regex pattern"): + wait_for_text( + pattern="[invalid", + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) From de621ecc3d1dba4813af2b88d6d4524f3e60ae3e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 18:24:27 -0500 Subject: [PATCH 47/47] mcp(fix[safety]): Harden search, destructive tools, and middleware why: Three independent reviews found converging correctness and safety gaps: literal search was broken for regex-looking strings, destructive tools allowed ambiguous fallback targeting, and untagged tools bypassed the safety middleware. what: - Add regex=False param to search_panes and wait_for_text; default to literal matching via re.escape(), preserving regex opt-in - Require exact IDs for destructive tools: kill_pane(pane_id), kill_window(window_id), kill_session(session_name|session_id) - Add self-kill guard: refuse to kill the pane/window/session/server hosting this MCP server (detected via TMUX_PANE env var) - Make safety middleware fail-closed: untagged tools are now denied - Add FastMCP native visibility (mcp.enable(tags=..., only=True)) as primary gate alongside middleware for better error messages - Log warning on invalid LIBTMUX_SAFETY env var instead of silent fallback - Return EnvironmentResult Pydantic model from show_environment instead of raw JSON string - Set on_duplicate="error" on FastMCP constructor - Update all affected tests --- src/libtmux/mcp/middleware.py | 14 ++++-- src/libtmux/mcp/models.py | 6 +++ src/libtmux/mcp/server.py | 26 ++++++++++- src/libtmux/mcp/tools/env_tools.py | 11 +++-- src/libtmux/mcp/tools/pane_tools.py | 60 +++++++++++++------------- src/libtmux/mcp/tools/server_tools.py | 10 +++++ src/libtmux/mcp/tools/session_tools.py | 14 ++++++ src/libtmux/mcp/tools/window_tools.py | 46 ++++++++------------ tests/mcp/test_env_tools.py | 15 +++---- tests/mcp/test_middleware.py | 6 +-- tests/mcp/test_pane_tools.py | 27 +++++++++--- tests/mcp/test_server_tools.py | 17 +++++++- tests/mcp/test_window_tools.py | 9 ++-- 13 files changed, 169 insertions(+), 92 deletions(-) diff --git a/src/libtmux/mcp/middleware.py b/src/libtmux/mcp/middleware.py index 358bda641..7041f4c23 100644 --- a/src/libtmux/mcp/middleware.py +++ b/src/libtmux/mcp/middleware.py @@ -35,11 +35,17 @@ def __init__(self, max_tier: str = TAG_MUTATING) -> None: self.max_level = _TIER_LEVELS.get(max_tier, 1) def _is_allowed(self, tags: set[str]) -> bool: - """Return True if the tool's tags fall within the allowed tier.""" + """Return True if the tool's tags fall within the allowed tier. + + Fail-closed: tools without a recognized tier tag are denied. + """ + found_tier = False for tier, level in _TIER_LEVELS.items(): - if tier in tags and level > self.max_level: - return False - return True + if tier in tags: + found_tier = True + if level > self.max_level: + return False + return found_tier async def on_list_tools( self, diff --git a/src/libtmux/mcp/models.py b/src/libtmux/mcp/models.py index 25579469c..563f9119e 100644 --- a/src/libtmux/mcp/models.py +++ b/src/libtmux/mcp/models.py @@ -114,6 +114,12 @@ class OptionSetResult(BaseModel): status: str = Field(description="Operation status") +class EnvironmentResult(BaseModel): + """Result of a show_environment call.""" + + variables: dict[str, str | bool] = Field(description="Environment variable mapping") + + class EnvironmentSetResult(BaseModel): """Result of a set_environment call.""" diff --git a/src/libtmux/mcp/server.py b/src/libtmux/mcp/server.py index 59c9ea0c0..e54693d45 100644 --- a/src/libtmux/mcp/server.py +++ b/src/libtmux/mcp/server.py @@ -5,14 +5,22 @@ from __future__ import annotations +import logging import os from fastmcp import FastMCP from libtmux.__about__ import __version__ -from libtmux.mcp._utils import TAG_MUTATING, VALID_SAFETY_LEVELS +from libtmux.mcp._utils import ( + TAG_DESTRUCTIVE, + TAG_MUTATING, + TAG_READONLY, + VALID_SAFETY_LEVELS, +) from libtmux.mcp.middleware import SafetyMiddleware +logger = logging.getLogger(__name__) + _BASE_INSTRUCTIONS = ( "libtmux MCP server for programmatic tmux control. " "tmux hierarchy: Server > Session > Window > Pane. " @@ -84,6 +92,11 @@ def _build_instructions(safety_level: str = TAG_MUTATING) -> str: _safety_level = os.environ.get("LIBTMUX_SAFETY", TAG_MUTATING) if _safety_level not in VALID_SAFETY_LEVELS: + logger.warning( + "invalid LIBTMUX_SAFETY=%r, falling back to %s", + _safety_level, + TAG_MUTATING, + ) _safety_level = TAG_MUTATING mcp = FastMCP( @@ -91,6 +104,7 @@ def _build_instructions(safety_level: str = TAG_MUTATING) -> str: version=__version__, instructions=_build_instructions(safety_level=_safety_level), middleware=[SafetyMiddleware(max_tier=_safety_level)], + on_duplicate="error", ) @@ -106,4 +120,14 @@ def _register_all() -> None: def run_server() -> None: """Run the MCP server.""" _register_all() + + # Use FastMCP's native visibility system as primary gate, + # with the SafetyMiddleware as a secondary layer for clear error messages. + allowed_tags = {TAG_READONLY} + if _safety_level in {TAG_MUTATING, TAG_DESTRUCTIVE}: + allowed_tags.add(TAG_MUTATING) + if _safety_level == TAG_DESTRUCTIVE: + allowed_tags.add(TAG_DESTRUCTIVE) + mcp.enable(tags=allowed_tags, only=True) + mcp.run() diff --git a/src/libtmux/mcp/tools/env_tools.py b/src/libtmux/mcp/tools/env_tools.py index 04b98b376..41f87ac2c 100644 --- a/src/libtmux/mcp/tools/env_tools.py +++ b/src/libtmux/mcp/tools/env_tools.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import typing as t from libtmux.mcp._utils import ( @@ -14,7 +13,7 @@ _resolve_session, handle_tool_errors, ) -from libtmux.mcp.models import EnvironmentSetResult +from libtmux.mcp.models import EnvironmentResult, EnvironmentSetResult if t.TYPE_CHECKING: from fastmcp import FastMCP @@ -25,7 +24,7 @@ def show_environment( session_name: str | None = None, session_id: str | None = None, socket_name: str | None = None, -) -> str: +) -> EnvironmentResult: """Show tmux environment variables. Parameters @@ -39,8 +38,8 @@ def show_environment( Returns ------- - str - JSON dict of environment variables. + EnvironmentResult + Environment variable mapping. """ server = _get_server(socket_name=socket_name) @@ -54,7 +53,7 @@ def show_environment( else: env_dict = server.show_environment() - return json.dumps(env_dict) + return EnvironmentResult(variables=env_dict) @handle_tool_errors diff --git a/src/libtmux/mcp/tools/pane_tools.py b/src/libtmux/mcp/tools/pane_tools.py index 6b5039fba..b3e660b79 100644 --- a/src/libtmux/mcp/tools/pane_tools.py +++ b/src/libtmux/mcp/tools/pane_tools.py @@ -200,24 +200,15 @@ def resize_pane( @handle_tool_errors def kill_pane( - pane_id: str | None = None, - session_name: str | None = None, - session_id: str | None = None, - window_id: str | None = None, + pane_id: str, socket_name: str | None = None, ) -> str: - """Kill (close) a tmux pane. + """Kill (close) a tmux pane. Requires exact pane_id (e.g. '%5'). Parameters ---------- - pane_id : str, optional - Pane ID (e.g. '%1'). - session_name : str, optional - Session name for pane resolution. - session_id : str, optional - Session ID (e.g. '$1') for pane resolution. - window_id : str, optional - Window ID for pane resolution. + pane_id : str + Pane ID (e.g. '%1'). Required — no fallback resolution. socket_name : str, optional tmux socket name. @@ -228,21 +219,16 @@ def kill_pane( """ from fastmcp.exceptions import ToolError - if all(v is None for v in (pane_id, session_name, session_id, window_id)): + caller = _get_caller_pane_id() + if caller is not None and pane_id == caller: msg = ( - "Refusing to kill without an explicit target. " - "Provide pane_id, or a window/session identifier." + "Refusing to kill the pane running this MCP server. " + "Use a manual tmux command if intended." ) raise ToolError(msg) server = _get_server(socket_name=socket_name) - pane = _resolve_pane( - server, - pane_id=pane_id, - session_name=session_name, - session_id=session_id, - window_id=window_id, - ) + pane = _resolve_pane(server, pane_id=pane_id) pid = pane.pane_id pane.kill() return f"Pane killed: {pid}" @@ -373,6 +359,7 @@ def clear_pane( @handle_tool_errors def search_panes( pattern: str, + regex: bool = False, session_name: str | None = None, session_id: str | None = None, match_case: bool = False, @@ -389,7 +376,11 @@ def search_panes( Parameters ---------- pattern : str - Text or regex pattern to search for in pane contents. + Text to search for in pane contents. Treated as literal text by + default. Set ``regex=True`` to interpret as a regular expression. + regex : bool + Whether to interpret pattern as a regular expression. Default False + (literal text matching). session_name : str, optional Limit search to panes in this session. session_id : str, optional @@ -410,9 +401,10 @@ def search_panes( """ from fastmcp.exceptions import ToolError + search_pattern = pattern if regex else re.escape(pattern) flags = 0 if match_case else re.IGNORECASE try: - compiled = re.compile(pattern, flags) + compiled = re.compile(search_pattern, flags) except re.error as e: msg = f"Invalid regex pattern: {e}" raise ToolError(msg) from e @@ -421,11 +413,11 @@ def search_panes( uses_scrollback = content_start is not None or content_end is not None - # Detect if pattern contains regex metacharacters that would break - # tmux's glob-based #{C:} filter. When regex is needed, skip the tmux - # fast path and capture all panes for Python-side matching. + # Detect if the effective pattern contains regex metacharacters that + # would break tmux's glob-based #{C:} filter. When regex is needed, + # skip the tmux fast path and capture all panes for Python-side matching. _REGEX_META = re.compile(r"[\\.*+?{}()\[\]|^$]") - is_plain_text = not _REGEX_META.search(pattern) + is_plain_text = not _REGEX_META.search(search_pattern) if not uses_scrollback and is_plain_text: # Phase 1: Fast filter via tmux's C-level window_pane_search(). @@ -500,6 +492,7 @@ def search_panes( @handle_tool_errors def wait_for_text( pattern: str, + regex: bool = False, pane_id: str | None = None, session_name: str | None = None, session_id: str | None = None, @@ -520,7 +513,11 @@ def wait_for_text( Parameters ---------- pattern : str - Text or regex pattern to wait for. + Text to wait for. Treated as literal text by default. Set + ``regex=True`` to interpret as a regular expression. + regex : bool + Whether to interpret pattern as a regular expression. Default False + (literal text matching). pane_id : str, optional Pane ID (e.g. '%1'). session_name : str, optional @@ -553,9 +550,10 @@ def wait_for_text( from libtmux.test.retry import retry_until + search_pattern = pattern if regex else re.escape(pattern) flags = 0 if match_case else re.IGNORECASE try: - compiled = re.compile(pattern, flags) + compiled = re.compile(search_pattern, flags) except re.error as e: msg = f"Invalid regex pattern: {e}" raise ToolError(msg) from e diff --git a/src/libtmux/mcp/tools/server_tools.py b/src/libtmux/mcp/tools/server_tools.py index 39bb2ec29..dfd7e88e7 100644 --- a/src/libtmux/mcp/tools/server_tools.py +++ b/src/libtmux/mcp/tools/server_tools.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import typing as t from libtmux.mcp._utils import ( @@ -114,6 +115,15 @@ def kill_server(socket_name: str | None = None) -> str: str Confirmation message. """ + if os.environ.get("TMUX_PANE"): + from fastmcp.exceptions import ToolError + + msg = ( + "Refusing to kill the tmux server while this MCP server is running " + "inside it. Use a manual tmux command if intended." + ) + raise ToolError(msg) + server = _get_server(socket_name=socket_name) server.kill() _invalidate_server(socket_name=socket_name) diff --git a/src/libtmux/mcp/tools/session_tools.py b/src/libtmux/mcp/tools/session_tools.py index e7ed66ac7..c49f3784b 100644 --- a/src/libtmux/mcp/tools/session_tools.py +++ b/src/libtmux/mcp/tools/session_tools.py @@ -14,6 +14,7 @@ TAG_MUTATING, TAG_READONLY, _apply_filters, + _get_caller_pane_id, _get_server, _resolve_session, _serialize_session, @@ -190,6 +191,19 @@ def kill_session( server = _get_server(socket_name=socket_name) session = _resolve_session(server, session_name=session_name, session_id=session_id) + + caller = _get_caller_pane_id() + if caller is not None: + caller_pane = server.panes.get(pane_id=caller, default=None) + if caller_pane is not None and caller_pane.session_id == session.session_id: + from fastmcp.exceptions import ToolError + + msg = ( + "Refusing to kill the session containing this MCP server's pane. " + "Use a manual tmux command if intended." + ) + raise ToolError(msg) + name = session.session_name or session.session_id session.kill() return f"Session killed: {name}" diff --git a/src/libtmux/mcp/tools/window_tools.py b/src/libtmux/mcp/tools/window_tools.py index 48474db2f..c23980ea5 100644 --- a/src/libtmux/mcp/tools/window_tools.py +++ b/src/libtmux/mcp/tools/window_tools.py @@ -14,6 +14,7 @@ TAG_MUTATING, TAG_READONLY, _apply_filters, + _get_caller_pane_id, _get_server, _resolve_pane, _resolve_session, @@ -220,24 +221,15 @@ def rename_window( @handle_tool_errors def kill_window( - window_id: str | None = None, - window_index: str | None = None, - session_name: str | None = None, - session_id: str | None = None, + window_id: str, socket_name: str | None = None, ) -> str: - """Kill (close) a tmux window. + """Kill (close) a tmux window. Requires exact window_id (e.g. '@3'). Parameters ---------- - window_id : str, optional - Window ID (e.g. '@1'). - window_index : str, optional - Window index within the session. - session_name : str, optional - Session name. - session_id : str, optional - Session ID. + window_id : str + Window ID (e.g. '@1'). Required — no fallback resolution. socket_name : str, optional tmux socket name. @@ -248,21 +240,21 @@ def kill_window( """ from fastmcp.exceptions import ToolError - if all(v is None for v in (window_id, window_index, session_name, session_id)): - msg = ( - "Refusing to kill without an explicit target. " - "Provide window_id, or window_index with a session identifier." - ) - raise ToolError(msg) + caller = _get_caller_pane_id() + if caller is not None: + server = _get_server(socket_name=socket_name) + window = _resolve_window(server, window_id=window_id) + caller_pane = server.panes.get(pane_id=caller, default=None) + if caller_pane is not None and caller_pane.window_id == window_id: + msg = ( + "Refusing to kill the window containing this MCP server's pane. " + "Use a manual tmux command if intended." + ) + raise ToolError(msg) + else: + server = _get_server(socket_name=socket_name) + window = _resolve_window(server, window_id=window_id) - server = _get_server(socket_name=socket_name) - window = _resolve_window( - server, - window_id=window_id, - window_index=window_index, - session_name=session_name, - session_id=session_id, - ) wid = window.window_id window.kill() return f"Window killed: {wid}" diff --git a/tests/mcp/test_env_tools.py b/tests/mcp/test_env_tools.py index a481d8599..d00322633 100644 --- a/tests/mcp/test_env_tools.py +++ b/tests/mcp/test_env_tools.py @@ -2,9 +2,9 @@ from __future__ import annotations -import json import typing as t +from libtmux.mcp.models import EnvironmentResult from libtmux.mcp.tools.env_tools import set_environment, show_environment if t.TYPE_CHECKING: @@ -13,10 +13,10 @@ def test_show_environment(mcp_server: Server, mcp_session: Session) -> None: - """show_environment returns environment variables.""" + """show_environment returns EnvironmentResult model.""" result = show_environment(socket_name=mcp_server.socket_name) - data = json.loads(result) - assert isinstance(data, dict) + assert isinstance(result, EnvironmentResult) + assert isinstance(result.variables, dict) def test_set_environment(mcp_server: Server, mcp_session: Session) -> None: @@ -38,8 +38,7 @@ def test_set_and_show_environment(mcp_server: Server, mcp_session: Session) -> N socket_name=mcp_server.socket_name, ) result = show_environment(socket_name=mcp_server.socket_name) - data = json.loads(result) - assert data.get("MCP_ROUND_TRIP") == "hello" + assert result.variables.get("MCP_ROUND_TRIP") == "hello" def test_show_environment_session(mcp_server: Server, mcp_session: Session) -> None: @@ -48,5 +47,5 @@ def test_show_environment_session(mcp_server: Server, mcp_session: Session) -> N session_name=mcp_session.session_name, socket_name=mcp_server.socket_name, ) - data = json.loads(result) - assert isinstance(data, dict) + assert isinstance(result, EnvironmentResult) + assert isinstance(result.variables, dict) diff --git a/tests/mcp/test_middleware.py b/tests/mcp/test_middleware.py index 70e01e227..0762605f2 100644 --- a/tests/mcp/test_middleware.py +++ b/tests/mcp/test_middleware.py @@ -77,12 +77,12 @@ class SafetyAllowedFixture(t.NamedTuple): tool_tags={TAG_DESTRUCTIVE}, expected_allowed=True, ), - # untagged tools are always allowed + # untagged tools are denied (fail-closed) SafetyAllowedFixture( - test_id="untagged_allowed_at_readonly", + test_id="untagged_denied_at_readonly", max_tier=TAG_READONLY, tool_tags=set(), - expected_allowed=True, + expected_allowed=False, ), ] diff --git a/tests/mcp/test_pane_tools.py b/tests/mcp/test_pane_tools.py index 0315e0c03..c29ebf82f 100644 --- a/tests/mcp/test_pane_tools.py +++ b/tests/mcp/test_pane_tools.py @@ -126,10 +126,10 @@ def test_resize_pane_zoom_mutual_exclusivity( ) -def test_kill_pane_requires_target(mcp_server: Server) -> None: - """kill_pane refuses to kill without an explicit target.""" - with pytest.raises(ToolError, match="Refusing to kill"): - kill_pane(socket_name=mcp_server.socket_name) +def test_kill_pane_requires_pane_id(mcp_server: Server) -> None: + """kill_pane requires pane_id as a positional argument.""" + with pytest.raises(ToolError, match="missing 1 required positional argument"): + kill_pane(socket_name=mcp_server.socket_name) # type: ignore[call-arg] def test_kill_pane(mcp_server: Server, mcp_session: Session) -> None: @@ -137,6 +137,7 @@ def test_kill_pane(mcp_server: Server, mcp_session: Session) -> None: window = mcp_session.active_window new_pane = window.split() pane_id = new_pane.pane_id + assert pane_id is not None result = kill_pane( pane_id=pane_id, socket_name=mcp_server.socket_name, @@ -155,6 +156,7 @@ class SearchPanesFixture(t.NamedTuple): test_id: str command: str pattern: str + regex: bool match_case: bool scope_to_session: bool expected_match: bool @@ -166,6 +168,7 @@ class SearchPanesFixture(t.NamedTuple): test_id="simple_match", command="echo FINDME_unique_string_12345", pattern="FINDME_unique_string_12345", + regex=False, match_case=False, scope_to_session=False, expected_match=True, @@ -175,6 +178,7 @@ class SearchPanesFixture(t.NamedTuple): test_id="case_insensitive_match", command="echo UPPERCASE_findme_test", pattern="uppercase_findme_test", + regex=False, match_case=False, scope_to_session=False, expected_match=True, @@ -184,6 +188,7 @@ class SearchPanesFixture(t.NamedTuple): test_id="case_sensitive_no_match", command="echo CaseSensitiveTest", pattern="casesensitivetest", + regex=False, match_case=True, scope_to_session=False, expected_match=False, @@ -193,6 +198,7 @@ class SearchPanesFixture(t.NamedTuple): test_id="case_sensitive_match", command="echo CaseSensitiveExact", pattern="CaseSensitiveExact", + regex=False, match_case=True, scope_to_session=False, expected_match=True, @@ -202,6 +208,7 @@ class SearchPanesFixture(t.NamedTuple): test_id="regex_pattern", command="echo error_code_42_found", pattern=r"error_code_\d+_found", + regex=True, match_case=False, scope_to_session=False, expected_match=True, @@ -211,6 +218,7 @@ class SearchPanesFixture(t.NamedTuple): test_id="no_match", command="echo nothing_special", pattern="XYZZY_nonexistent_pattern_99999", + regex=False, match_case=False, scope_to_session=False, expected_match=False, @@ -220,6 +228,7 @@ class SearchPanesFixture(t.NamedTuple): test_id="scoped_to_session", command="echo session_scoped_marker", pattern="session_scoped_marker", + regex=False, match_case=False, scope_to_session=True, expected_match=True, @@ -240,6 +249,7 @@ def test_search_panes( test_id: str, command: str, pattern: str, + regex: bool, match_case: bool, scope_to_session: bool, expected_match: bool, @@ -257,6 +267,7 @@ def test_search_panes( kwargs: dict[str, t.Any] = { "pattern": pattern, + "regex": regex, "match_case": match_case, "socket_name": mcp_server.socket_name, } @@ -338,11 +349,12 @@ def test_search_panes_includes_window_and_session_names( assert match.session_name == mcp_session.session_name -def test_search_panes_invalid_regex(mcp_server: Server) -> None: - """search_panes raises ToolError on invalid regex.""" +def test_search_panes_invalid_regex(mcp_server: Server, mcp_session: Session) -> None: + """search_panes raises ToolError on invalid regex when regex=True.""" with pytest.raises(ToolError, match="Invalid regex pattern"): search_panes( pattern="[invalid", + regex=True, socket_name=mcp_server.socket_name, ) @@ -484,10 +496,11 @@ def test_wait_for_text( def test_wait_for_text_invalid_regex(mcp_server: Server, mcp_pane: Pane) -> None: - """wait_for_text raises ToolError on invalid regex.""" + """wait_for_text raises ToolError on invalid regex when regex=True.""" with pytest.raises(ToolError, match="Invalid regex pattern"): wait_for_text( pattern="[invalid", + regex=True, pane_id=mcp_pane.pane_id, socket_name=mcp_server.socket_name, ) diff --git a/tests/mcp/test_server_tools.py b/tests/mcp/test_server_tools.py index 12a4d8c0b..bebf5e585 100644 --- a/tests/mcp/test_server_tools.py +++ b/tests/mcp/test_server_tools.py @@ -191,7 +191,22 @@ def test_list_sessions_with_filters( assert len(result) >= 1 -def test_kill_server(mcp_server: Server, mcp_session: Session) -> None: +def test_kill_server( + mcp_server: Server, mcp_session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: """kill_server kills the tmux server.""" + # Remove TMUX_PANE to bypass self-kill guard (test server is separate) + monkeypatch.delenv("TMUX_PANE", raising=False) result = kill_server(socket_name=mcp_server.socket_name) assert "killed" in result.lower() + + +def test_kill_server_self_kill_guard( + mcp_server: Server, mcp_session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + """kill_server refuses when running inside tmux.""" + from fastmcp.exceptions import ToolError + + monkeypatch.setenv("TMUX_PANE", "%99") + with pytest.raises(ToolError, match="Refusing to kill"): + kill_server(socket_name=mcp_server.socket_name) diff --git a/tests/mcp/test_window_tools.py b/tests/mcp/test_window_tools.py index 7539403ba..73b46ab2f 100644 --- a/tests/mcp/test_window_tools.py +++ b/tests/mcp/test_window_tools.py @@ -202,16 +202,17 @@ def test_list_panes_with_filters( assert len(result) >= expected_min_count -def test_kill_window_requires_target(mcp_server: Server) -> None: - """kill_window refuses to kill without an explicit target.""" - with pytest.raises(ToolError, match="Refusing to kill"): - kill_window(socket_name=mcp_server.socket_name) +def test_kill_window_requires_window_id(mcp_server: Server) -> None: + """kill_window requires window_id as a positional argument.""" + with pytest.raises(ToolError, match="missing 1 required positional argument"): + kill_window(socket_name=mcp_server.socket_name) # type: ignore[call-arg] def test_kill_window(mcp_server: Server, mcp_session: Session) -> None: """kill_window kills a window.""" new_window = mcp_session.new_window(window_name="mcp_kill_win") window_id = new_window.window_id + assert window_id is not None result = kill_window( window_id=window_id, socket_name=mcp_server.socket_name,