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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ jobs:
- name: Build documentation
if: env.PUBLISH == 'true'
run: |
cd docs && just html
cd docs && just sphinxopts="-W --keep-going" html

- name: Configure AWS Credentials
if: env.PUBLISH == 'true'
Expand Down
105 changes: 105 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,81 @@
_Notes on upcoming releases will be added here_
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### Dependencies

**Minimum `fastmcp>=4.0.2`** (was `>=3.4.2,<4.0.0`)

libtmux-mcp tracks the FastMCP 4 line. FastMCP 4 builds on MCP Python SDK
v2, which brings `mcp>=2.0.0`, `mcp-types` and pydantic 2.12 with it, and
replaces httpx with httpx2 across FastMCP's HTTP stack. No tool, resource or
prompt changes shape, and one server serves both the session-based and the
sessionless `2026-07-28` protocol eras, negotiated per connection.

**FastMCP releases are adopted without waiting out the cooldown**

The dependency cooldown held every FastMCP release back, so a refreshed lock
resolved to a patch behind the one being tested against. `fastmcp` and its
`fastmcp-slim` companion are exempt from it.

### What's new

**`tmux://` templates answer argument completion**

The templates take live tmux identifiers, and MCP publishes a template's URI
but never its parameter domain, so a reader had no way to discover a valid
session name or pane id from the listing. A completion handler offers them
from the live server, filtered by what has been typed and scoped by an
already-supplied `socket_name`. A server that is gone yields no suggestions
rather than an error.

Whether suggestions appear depends on the client. The MCP Inspector's browser
UI and VS Code send `completion/complete`; measured against five agent CLIs,
none do.

### Fixes

**`tmux://` reads keep accepting tmux names that look like paths**

FastMCP 4 screens templated resource parameters before a handler runs. Its
absolute-path check does not fit tmux identifiers: it rejects the socket paths
`list_servers` reports, and reads any `<letter>:<rest>` session name — which
tmux accepts — as a Windows drive-relative path. Both stay readable, so
`tmux://` and the equivalent tools continue to agree on which sessions and
servers exist.

Traversal and null-byte screening remain on, and are set per template rather
than server-wide. `socket_name` reaches `tmux -L`, which appends it to the
socket directory without normalising, so `../..` really does place the socket
elsewhere; exempting the parameter outright would drop that guard along with
the absolute-path check.

**A `tmux://` read that fails now says so as `-32603`**

FastMCP's `ErrorHandlingMiddleware` matches not-found exceptions by exact
class, so only an unknown resource URI still maps to `-32002`. A missing
session raises `ResourceError`, and a rejected path raises
`ResourceSecurityError` — a `NotFoundError` subclass, and so not an exact
match — and both reach the client as `-32603` "Internal error". The message is
unchanged; only clients matching on the numeric code are affected.

**A wait tool survives a context that carries no session**

`Context.session` raises rather than degrading when there is no request
context, so `ctx.warning` and `ctx.report_progress` took the whole tool call
down in any execution that lacks one. The wait helpers check before
delivering: the call returns its normal result, without notifications, instead
of failing. Notification delivery on an ordinary request is unchanged.

### Documentation

**The completion topic describes the handlers the server registers**

The page said libtmux-mcp registered no completion handlers and offered
workarounds for the gap. It describes what the `tmux://` templates complete,
and states the caveat where it now belongs: whether suggestions appear depends
on the client. The retired claims are pinned as forbidden text, so the page
cannot drift back to underclaiming a shipped feature.

**The toolset migration notes reach the migration page**

The 0.1.0a21 notes mapping the retired tiers onto `inspect`, `manage`,
Expand All @@ -17,6 +90,38 @@ of `MIGRATION`, and they state that `LIBTMUX_SAFETY` raises while the server
module loads — every value, the former default `mutating` included — and that
the former default is reached by removing the variable and setting nothing.

### Development

**A Sphinx warning fails the documentation build**

The docs workflow passes `-W --keep-going`, so a warning stops the build that
publishes the site instead of scrolling past in a green run.

**Path-like session-name tests skip on tmux below 3.7**

tmux before 3.7 rewrites `:` and `.` in a session name to `_`, turning `a:1`
into `a_1`, and exits 0 with no stderr — so a caller cannot tell from the
result. The resource tests that need such a name check what tmux stored and
skip when it differs, naming the rewrite. They ran only against a newer tmux
before, and reported the resulting absent session as a failed read on every
supported tmux in CI.

**The test suite runs without FastMCP's camelCase compatibility shims**

FastMCP 4 answers MCP SDK v1 attribute spellings such as `tool.inputSchema`
through warn-once shims it plans to remove. Neither gate catches a test that
depends on one: the type checker sees `Any`, and the warning fires once per
attribute for the whole run. The suite sets `FASTMCP_MCP_CAMELCASE_COMPAT=false`,
so a surviving camelCase read raises `AttributeError` and fails the test that
made it.

**The compatibility reference is bound to the manifest**

The dependency table restated floors by hand and had drifted: it named libtmux
0.55 against a manifest requiring 0.62, and omitted `regex` entirely. A test
compares the table against `[project].dependencies` as version ranges, so the
next floor that moves without its documentation fails the suite.

## libtmux-mcp 0.1.0a21 (2026-08-30)

libtmux-mcp 0.1.0a21 replaces cumulative safety tiers with independently
Expand Down
13 changes: 13 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from __future__ import annotations

import os
import shutil
import typing as t

Expand All @@ -24,6 +25,18 @@
if t.TYPE_CHECKING:
import pathlib

# Run the suite without fastmcp's camelCase compatibility bridge.
# fastmcp 4 still answers SDK v1 spellings like `tool.inputSchema`
# through warn-once shims scheduled for removal, and neither gate
# catches a read that relies on one: mypy sees `Any`, and the warning
# fires once per attribute for the whole run. With the bridge off a
# surviving read raises AttributeError and fails its own test.
#
# Set through the environment, before anything imports fastmcp (its
# settings singleton is built at import), so subprocess tests inherit
# it via os.environ.copy().
os.environ.setdefault("FASTMCP_MCP_CAMELCASE_COMPAT", "false")

pytest_plugins = ["pytester", "sphinx.testing.fixtures"]


Expand Down
5 changes: 3 additions & 2 deletions docs/reference/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@

| Package | Required version |
|---------|-----------------|
| [libtmux](https://libtmux.git-pull.com/) | >= 0.55.0, < 1.0 |
| [FastMCP](https://github.com/jlowin/fastmcp) | >= 3.1.0, < 4.0.0 |
| [libtmux](https://libtmux.git-pull.com/) | >= 0.62.0, < 1.0 |
| [FastMCP](https://github.com/jlowin/fastmcp) | >= 4.0.2, < 5.0.0 |
| [regex](https://github.com/mrabarnett/mrab-regex) | >= 2024.11.6 |

## MCP clients

Expand Down
32 changes: 21 additions & 11 deletions docs/topics/completion.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
The
[MCP completion](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion)
protocol lets clients ask a server for argument suggestions. libtmux-mcp
does not currently register custom completion handlers.
answers it for the ``tmux://`` resource templates, offering the session
names, window indexes and pane ids the live server currently has.

## What the spec does

Expand All @@ -17,20 +18,29 @@ session picker popup when filling ``session_name=`` on

## What libtmux-mcp currently exposes

- **Prompt arguments** — the four recipes ({doc}`/prompts`)
advertise their argument names and types through their schemas.
- **Resource template parameters** —
{doc}`/resources` URIs carry ``{session_name}``,
``{window_index}``, ``{pane_id}``, and ``{?socket_name}``
placeholders.
- **Resource template parameters** — {doc}`/resources` URIs carry
``{session_name}``, ``{window_index}``, ``{pane_id}`` and
``{?socket_name}``. The first three complete from the live server,
filtered by what has been typed. Supplying ``socket_name`` first
scopes the suggestions to that server; a server that is gone yields
no suggestions rather than an error.
- **Prompt arguments** — the four recipes ({doc}`/prompts`) advertise
their argument names and types through their schemas. They take free
text rather than tmux identifiers, so they have no domain to
enumerate.

MCP publishes a template's URI but never its parameter domain, so
without this a reader has no way to discover a valid session name or
pane id from the template listing alone.

```{warning}
Clients should not rely on ``completion/complete`` returning live tmux
suggestions, schema-derived examples, or enum-like values today.
Adding live suggestions requires dedicated completion handlers.
Whether you see suggestions depends on the client, not the server.
Completion serves human-facing hosts — the MCP Inspector's browser UI
and VS Code send ``completion/complete``. Measured against five agent
CLIs, none send it, so an agent sees no difference.
```

## Workarounds for clients that need live enumeration
## Enumerating from a client that does not complete

Agents that need to pick a real session / window / pane can call
{tool}`list-sessions`, {tool}`list-windows`, or {tool}`list-panes`
Expand Down
18 changes: 11 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ include = [

dependencies = [
"libtmux>=0.62.0,<1.0",
"fastmcp>=3.4.2,<4.0.0",
"fastmcp>=4.0.2,<5.0.0",
"regex>=2024.11.6",
]

Expand All @@ -57,9 +57,9 @@ libtmux-mcp = "libtmux_mcp:main"
[dependency-groups]
dev = [
# Docs
"gp-sphinx==0.1.0a38",
"sphinx-autodoc-api-style==0.1.0a38",
"sphinx-autodoc-fastmcp==0.1.0a38",
"gp-sphinx==0.1.0a39",
"sphinx-autodoc-api-style==0.1.0a39",
"sphinx-autodoc-fastmcp==0.1.0a39",
"gp-libs>=0.0.19",
"sphinx-autobuild",
# Testing
Expand All @@ -73,6 +73,8 @@ dev = [
"syrupy>=5.1.0",
# scripts/mcp_swap.py (PEP 723 script; dep listed here so tests can import it)
"tomlkit>=0.13",
# tests/docs/test_compatibility.py compares version ranges, not strings.
"packaging",
# Coverage
"codecov",
"coverage",
Expand All @@ -84,9 +86,9 @@ dev = [
]

docs = [
"gp-sphinx==0.1.0a38",
"sphinx-autodoc-api-style==0.1.0a38",
"sphinx-autodoc-fastmcp==0.1.0a38",
"gp-sphinx==0.1.0a39",
"sphinx-autodoc-api-style==0.1.0a39",
"sphinx-autodoc-fastmcp==0.1.0a39",
"gp-libs>=0.0.19",
"sphinx-autobuild",
]
Expand Down Expand Up @@ -138,6 +140,8 @@ sphinx-gp-sitemap = false
sphinx-gp-theme = false
sphinx-ux-autodoc-layout = false
sphinx-ux-badges = false
fastmcp = false
fastmcp-slim = false

[tool.mypy]
strict = true
Expand Down
19 changes: 14 additions & 5 deletions src/libtmux_mcp/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@
from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware
from fastmcp.server.transforms import Transform, Visibility
from fastmcp.tools.base import ToolResult
from fastmcp.tools import ToolResult
from mcp.types import CallToolRequestParams, TextContent
from pydantic import ValidationError as PydanticValidationError

from libtmux_mcp._utils import TOOLSET_INSPECT, VALID_TOOLSETS, ExpectedToolError

if t.TYPE_CHECKING:
from fastmcp.server.transforms import GetToolNext
from fastmcp.tools.base import Tool
from fastmcp.tools import Tool
from fastmcp.utilities.versions import VersionSpec


Expand Down Expand Up @@ -406,9 +406,18 @@ class ToolErrorResultMiddleware(ErrorHandlingMiddleware):
intercepts tool-call exceptions first (``on_call_tool`` is the
innermost hook of a middleware's chain) and returns
:func:`_error_tool_result` instead; non-tool messages fall through
to the inherited ``on_message``, preserving the MCP ``-32002``
resource-not-found transform this middleware was originally
adopted for.
to the inherited ``on_message``.

That inherited transform matches the not-found exception types by
exact class -- ``error_type in (FileNotFoundError, KeyError,
NotFoundError)`` -- so only an unknown resource URI, which fastmcp
raises as ``NotFoundError`` itself, reaches a client as ``-32002``.
The ``tmux://`` handlers raise :exc:`~fastmcp.exceptions.ResourceError`
for a missing session, and path screening raises
``ResourceSecurityError``, a ``NotFoundError`` *subclass*; neither is
an exact match, so both arrive as ``-32603`` "Internal error" -- the
code that tells a client the server is broken, for what is really an
absent or disallowed target.

Logging honors ``FastMCPError.log_level`` (fastmcp >= 3.3): the
expected failures demoted to WARNING by
Expand Down
10 changes: 5 additions & 5 deletions src/libtmux_mcp/prompts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

if t.TYPE_CHECKING:
from fastmcp import FastMCP
from fastmcp.tools.base import Tool
from fastmcp.tools import Tool

#: Env-var gate that enables exposing prompts as tools for clients that
#: do not speak the MCP prompts protocol. Off by default — a sprawling
Expand All @@ -39,10 +39,10 @@

_PROMPT_TOOL_NAMES = frozenset({"list_prompts", "get_prompt"})
_PROMPT_TOOL_ANNOTATIONS = ToolAnnotations(
readOnlyHint=True,
destructiveHint=False,
idempotentHint=True,
openWorldHint=False,
read_only_hint=True,
destructive_hint=False,
idempotent_hint=True,
open_world_hint=False,
)

__all__ = ["ENV_PROMPTS_AS_TOOLS", "register_prompts"]
Expand Down
1 change: 1 addition & 0 deletions src/libtmux_mcp/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ def register_resources(mcp: FastMCP) -> None:
from libtmux_mcp.resources import hierarchy

hierarchy.register(mcp)
hierarchy.register_completions(mcp)
Loading