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
15 changes: 13 additions & 2 deletions .agents/skills/testing-mcp-with-cli-agents/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
name: testing-mcp-with-cli-agents
description: >-
Test an MCP server by driving real CLI agents (Claude, Codex, Cursor, Gemini,
Grok, agy) against it, using isolated tmux sockets and send-keys instead of
Grok, agy, opencode) against it, using isolated tmux sockets and send-keys
instead of
trusting unit tests alone. Use this whenever verifying MCP-server behavior
end-to-end, checking that a local branch or checkout works across installed
agent CLIs, comparing trunk-vs-branch MCP behavior, driving an interactive
Expand Down Expand Up @@ -195,7 +196,17 @@ transcripts), and the **ground-truth socket state** after the run.
## Wiring a checkout into the CLIs: mcp_swap

`scripts/mcp_swap.py` rewrites each CLI's config to `uv --directory <repo> run
<entry>` and preserves existing env on replacement:
<entry>` and preserves existing env on replacement. It covers eight CLIs; the
two newest are not yet driven through this harness, so
`references/cli-matrix.md` has no verified row for them:

- **opencode** — `$XDG_CONFIG_HOME/opencode/opencode.jsonc`. JSONC, so
comments survive a swap; the entry packs argv into one `command` array
under a top-level `mcp` key, and its env table is spelled `environment`.
A scalar `command` there is a decode error that stops opencode starting.
- **pi** — `~/.pi/agent/mcp.json`. pi ships no MCP client of its own; that
file is read by the third-party `pi-mcp-adapter` extension, so a swap
does nothing until it is installed. `detect` reports this.

```console
$ uv run scripts/mcp_swap.py detect # which CLIs are present
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,28 @@ below.
| gemini | `gemini -p` | project `.gemini/settings.json` from cwd | `gemini mcp list` | `--approval-mode yolo` (`--skip-trust`) | no — `IneligibleTierError`, CLI unsupported for individuals |
| grok | `grok -p` / `--single` | `GROK_HOME` **or** `mcp add --scope project` | `grok mcp doctor tmux --json` (real handshake) | `--permission-mode bypassPermissions` | yes |
| agy | `agy -p` | hidden `--gemini_dir <path>` (**credentials do not follow it — copy the token in**) | none short of a model call | `--dangerously-skip-permissions` | yes |
| opencode | not yet verified | not yet verified | not yet verified | not yet verified | not yet driven through this harness |
| pi | n/a — no MCP client (see below) | n/a | n/a | n/a | n/a |

## Per-CLI detail

### opencode and pi — registered in mcp_swap, not yet driven here

`mcp_swap` writes both, but neither has been taken through the tmux harness, so
the row above is blank rather than guessed. What is known from the source:

- **opencode** stores MCP servers under a top-level `mcp` key in
`$XDG_CONFIG_HOME/opencode/opencode.jsonc`, as
`{"type": "local", "command": [argv...], "environment": {...}}`. `command` is
one array, not a command/args pair, and the env table is `environment` — an
`env` key is dropped in silence, while a scalar `command` fails the whole
config's decode and stops opencode starting. `opencode mcp add <name> -- <cmd>`
is non-interactive once both a name and a `--` command are given.
- **pi** has no MCP client at all: its README says "No MCP", and the released
build contains no MCP code. `~/.pi/agent/mcp.json` is a convention of the
third-party `pi-mcp-adapter` extension. Until that package is installed,
nothing reads what a swap writes, and there is no agent behavior to drive.

### codex — two isolation styles, both verified
- **Config-less (leanest):** a home dir containing only a **copy** of the real
`auth.json`, no `config.toml`, plus `-c` overrides:
Expand Down
53 changes: 53 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,59 @@
_Notes on upcoming releases will be added here_
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### Documentation

#### opencode joins the install picker

The install widget gains an opencode panel. `opencode mcp add tmux -- <command>`
is non-interactive once a name and a `--` command are both given, so it is a CLI
panel rather than a paste-this-JSON one — which also sidesteps opencode's
unusual entry shape.

### Development

**`mcp_swap.py` covers opencode and pi**

`use-local`, `status`, `revert`, `doctor` and `detect` now reach two more agent
CLIs.

opencode is the first config the script edits that is not plain JSON or TOML.
Its `$XDG_CONFIG_HOME/opencode/opencode.jsonc` is JSONC, its server map hangs
off a top-level `mcp` key rather than `mcpServers`, and one entry packs argv
into a single `command` array with its environment table spelled
`environment`. Getting the shape wrong is not a soft failure there: a scalar
`command` is a decode error that stops opencode starting, and an `env` key is
dropped without a word. Comments survive a swap, including one written directly
above the `command` it explains.

pi ships no MCP client — its README says so outright, and the released build
contains no MCP code. `~/.pi/agent/mcp.json` is read by the third-party
`pi-mcp-adapter` extension, so a swap written there takes effect only once that
package is installed. `detect` says so rather than reporting a swap that cannot
do anything.

**JSONC is edited rather than reserialized**

The JSON writer rebuilds the whole document, which for a commented file would
mean deleting every comment in it. JSONC values now come from stdlib `json`
after comments and trailing commas are blanked in place, and writes are applied
as text splices, so every byte outside a replaced value is untouched. The
obvious dependency was measured and rejected: `json-five` round-trips comments,
but raises on the valid JSON string `"C:\\x"` and silently decodes a literal
`\u0041` to `"A"`.

**Per-CLI behavior is declared, not branched**

`CLIInfo` gained `container` (the key path to the server map) and `dialect` (the
entry shape) alongside `fmt`. The four `cli in (...)` membership tuples that
`get_server`, `set_server`, `delete_server` and `_all_server_specs` each carried
are gone. Two of them ended in a bare `else` that fell through to the TOML key,
so a CLI registered but forgotten in one tuple reported "no entry" instead of
failing; the other two raised `AssertionError`, which the caller did not catch.
A non-mapping at a container key now raises a `RuntimeError` naming the path for
every CLI, not just Claude. `scripts/README.md`'s extension guide described
three branch sites when there were four; it now describes the fields instead.

## libtmux-mcp 0.1.0a20 (2026-08-09)

libtmux-mcp 0.1.0a20 changes no tool behavior. `scripts/mcp_swap.py` gains `use-local --pr N`, which points installed agent CLIs at a pull request without a checkout and verifies the server before it rewrites any configuration, and its edits now preserve unrelated config text, file permissions, and symlink targets. ruff's curated default rule set is enabled behind a `ruff>=0.16.0` floor, taking the project from 351 enabled rules to 565 and fixing what that surfaced, and the CI workflow actions move to their current majors. In the documentation, the dataclass identifying an MCP caller describes each of its fields instead of reaching the API reference as "Alias for field number 0".
Expand Down
26 changes: 26 additions & 0 deletions docs/_ext/widgets/mcp_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,18 @@ class Panel:
),
)

#: User scope only: `opencode mcp add` writes the global config whether or
#: not a project one exists, so a Project panel would advertise a file the
#: command it prints never touches.
_OPENCODE_SCOPES: tuple[Scope, ...] = (
Scope(
id="user",
label="User",
config_file="~/.config/opencode/opencode.jsonc",
note=None,
),
)

_GROK_SCOPES: tuple[Scope, ...] = (
Scope(
id="user",
Expand Down Expand Up @@ -238,6 +250,12 @@ class Panel:
kind="json",
scopes=_ANTIGRAVITY_SCOPES,
),
Client(
id="opencode",
label="opencode",
kind="cli",
scopes=_OPENCODE_SCOPES,
),
)


Expand Down Expand Up @@ -377,6 +395,14 @@ def _cli_body(client: Client, scope: Scope, method: Method, cooldown: Cooldown)
# ``--`` is handed to the server process verbatim.
if client.id == "grok":
return f"grok mcp add --scope {scope.id} tmux -- {tool_cmd}"
# opencode: ``opencode mcp add <name> -- <cmd>`` is non-interactive as
# soon as a name and a ``--`` command are both given, and it writes
# whichever config file is in scope for the current directory. The
# stored entry packs argv into a single ``command`` array rather than
# the command/args pair the JSON-kind clients use, which is why this
# is a CLI panel and not a paste-this-JSON one.
if client.id == "opencode":
return f"opencode mcp add tmux -- {tool_cmd}"
# codex: CLI doesn't write project scope; the project-scope panel
# uses the TOML body path (see ``_body_for``).
return f"codex mcp add tmux -- {tool_cmd}"
Expand Down
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ watch-mypy:
format-markdown:
prettier --parser=markdown -w *.md docs/*.md docs/**/*.md CHANGES

# Detect which CLI agents (claude/codex/cursor/gemini) exist on this machine
# Detect which agent CLIs exist on this machine
[group: 'mcp']
mcp-detect:
uv run scripts/mcp_swap.py detect
Expand Down
69 changes: 52 additions & 17 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,26 +131,41 @@ the user-level fallback; the project entry stays. `revert` without

### Scope

Covers four CLIs and their canonical **global** config paths:

| CLI | Config | Format |
|--------|-------------------------------|--------|
| Claude | `~/.claude.json` | JSON (per-project keying) |
| Codex | `~/.codex/config.toml` | TOML (format-preserving via `tomlkit`) |
| Cursor | `~/.cursor/mcp.json` | JSON |
| Gemini | `~/.gemini/settings.json` | JSON |
Covers eight CLIs and their canonical **global** config paths:

| CLI | Config | Format |
|-----|--------|--------|
| Claude | `~/.claude.json` | JSON (per-project keying) |
| Codex | `~/.codex/config.toml` | TOML (format-preserving via `tomlkit`) |
| Cursor | `~/.cursor/mcp.json` | JSON |
| Gemini | `~/.gemini/settings.json` | JSON |
| Grok | `~/.grok/config.toml` | TOML (same shape as Codex) |
| agy | `~/.gemini/config/mcp_config.json` | JSON |
| opencode | `$XDG_CONFIG_HOME/opencode/opencode.jsonc` | JSONC (comments preserved) |
| pi | `~/.pi/agent/mcp.json` | JSONC (read by `pi-mcp-adapter`, not by pi) |

Claude's config is keyed per-project under the repo's absolute path — the
script writes only under the current repo's key, leaving other projects'
entries untouched.

#### Out of scope (use the CLI's native command)

- **Workspace / project-local configs** for Cursor and Gemini
(`$PWD/.cursor/mcp.json`, `$PWD/.gemini/settings.json`). When
workspace precedence matters, use `cursor mcp add` / `gemini mcp add`
directly — workspace files take precedence over the global ones this
script writes.
- **Workspace / project-local configs** for Cursor, Gemini and opencode
(`$PWD/.cursor/mcp.json`, `$PWD/.gemini/settings.json`,
`$PWD/opencode.json`). When workspace precedence matters, use
`cursor mcp add` / `gemini mcp add` directly — workspace files take
precedence over the global ones this script writes. opencode has no
non-interactive project-scope add (`opencode mcp add` writes the global
file), so edit `$PWD/opencode.json` by hand.
- **opencode's sibling global files.** opencode merges `config.json`,
`opencode.json` and `opencode.jsonc` from the same directory, with
`.jsonc` winning. This script writes `.jsonc`, so its entry is the one
that takes effect, but a stale `mcp.<name>` in a sibling
`opencode.json` merges underneath rather than being shadowed.
- **pi without `pi-mcp-adapter`.** pi ships no MCP client. The file this
script writes is read by that third-party extension, so until it is
installed the swap has no effect — `detect` reports this rather than
claiming otherwise.
- **Custom binary install locations.** Detection is `shutil.which` plus
the file existing at the configured global path. Homebrew, npm
prefixes (`~/.npm-global/bin`), and the canonical local-install
Expand All @@ -159,7 +174,27 @@ entries untouched.

### Extending to a new CLI

Add an entry to the `CLIS` table in `mcp_swap.py` and extend the three
per-CLI branches in `get_server` / `set_server` / `delete_server`. Tests
in `tests/test_mcp_swap.py` use a `fake_home` fixture that monkeypatches
`CLIS`, so the extension pattern is already established.
Add an entry to the `CLIS` table in `mcp_swap.py`. Each `CLIInfo` carries
the three things that vary per CLI, so no `get_server` / `set_server` /
`delete_server` / `_all_server_specs` branch needs touching:

- `fmt` — `json`, `jsonc` or `toml`, selecting the reader and writer
- `container` — the key path to the server map, e.g. `("mcpServers",)`
or `("mcp",)`
- `dialect` — the shape of one entry: `standard` (scalar `command`,
sibling `args`, optional `env`), `claude` (adds `type` and always
writes `env`), or `opencode` (one `command` array, env under
`environment`)

Add the name to `CLIName` and `ALL_CLIS` too — a CLI in `CLIS` but not
`ALL_CLIS` has its state entries dropped on load, so `revert` forgets the
swap and leaves the config rewritten.

A dialect no existing CLI speaks needs a branch in
`McpServerSpec.to_entry_dict` and its mirror in `_spec_from_entry`; that
mirror is what keeps `is_local_uv_directory`, `local_repo_path` and
`pr_ref` working, and those drive the "already local" short-circuit.

Tests in `tests/test_mcp_swap.py` use a `fake_home` fixture that
monkeypatches `CLIS` wholesale, so every new CLI must be added there as
well — `test_fake_home_covers_every_registered_cli` enforces it.
Loading