From 28c1348c79e438966f2f80e4f5c9d08c8f14b1db Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 2 Jun 2026 15:54:41 -0300 Subject: [PATCH 1/3] feat(install): register Claude Code MCP server at user scope `/pipefy:install` previously installed `pipefy_mcp_server` as a uv tool that the Claude Code plugin's bundled `.mcp.json` (uvx) never referenced, so a plugin user who ran it ended up materializing the server twice. Make the installer the single owner of the Claude Code server registration instead: `install.sh --client claude-code` now runs `claude mcp add pipefy --scope user -- pipefy-mcp-server`. By Claude Code's name precedence (local > project > user > plugin) the user-scope entry shadows the plugin's bundled entry, so only the installed binary spawns, on the same system Python the CLI uses. The committed `.mcp.json` (uvx) stays as the zero-config default for the marketplace one-liner. Shadowing means plugin auto-updates no longer reach the running server, so add a SessionStart hook (hooks/check-server-version.sh) that nudges the user to re-run `/pipefy:install` when the installed server version drifts from the plugin's. It no-ops for users on the pure plugin/uvx path. `/pipefy:install` switches from `--client none` to `--client claude-code`. --- .claude-plugin/plugin.json | 14 ++++++++++- commands/install.md | 6 ++--- hooks/check-server-version.sh | 29 ++++++++++++++++++++++ install.sh | 45 +++++++++++++++++++++++++++++------ 4 files changed, 83 insertions(+), 11 deletions(-) create mode 100755 hooks/check-server-version.sh diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9eac123f..8a75d29d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -6,5 +6,17 @@ "author": { "name": "Gabriel Custodio" }, "homepage": "https://github.com/gbrlcustodio/pipefy-mcp-server", "repository": "https://github.com/gbrlcustodio/pipefy-mcp-server", - "license": "Apache-2.0" + "license": "Apache-2.0", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/check-server-version.sh\"" + } + ] + } + ] + } } diff --git a/commands/install.md b/commands/install.md index 3a95cbb6..d047e9df 100644 --- a/commands/install.md +++ b/commands/install.md @@ -1,6 +1,6 @@ --- name: install -description: Install the Pipefy CLI (pipefy-auth + pipefy-cli + pipefy-infra + pipefy-sdk) as a persistent uv tool. +description: Install the Pipefy CLI and MCP server as persistent uv tools, and register the server in Claude Code at user scope. disable-model-invocation: true --- @@ -10,9 +10,9 @@ Otherwise prompt the user to confirm running: ``` curl -fsSL https://raw.githubusercontent.com/gbrlcustodio/pipefy-mcp-server/main/install.sh \ - | sh -s -- --yes --no-skills --client none + | sh -s -- --yes --no-skills --client claude-code ``` -The installer resolves the latest GitHub Release at runtime and runs `uv tool install` with the discovered wheel URLs. `--client none` skips MCP-client config writes; the Claude Code plugin's `.mcp.json` already wires the MCP server. +The installer resolves the latest GitHub Release at runtime and runs `uv tool install` with the discovered wheel URLs. `--client claude-code` registers the MCP server via `claude mcp add` at user scope, which takes precedence over the plugin's bundled `.mcp.json` entry so the system-Python binary is the one that runs. Reload or restart Claude Code afterward for the registration to take effect. Verify with `pipefy --version`. diff --git a/hooks/check-server-version.sh b/hooks/check-server-version.sh new file mode 100755 index 00000000..e5024492 --- /dev/null +++ b/hooks/check-server-version.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# SessionStart nudge: when install.sh has registered a user-scope `pipefy` +# server (the persistent pipefy-mcp-server binary, which shadows the plugin's +# bundled uvx entry), plugin auto-updates no longer reach that running server. +# This compares the installed binary's version against the plugin's declared +# version and, on drift, asks the user to re-run /pipefy:install. +# +# No-op for users on the pure plugin/uvx path: they have no installed binary, +# so there is nothing shadowing the plugin and nothing to update. +set -eu + +# No installed binary means nothing shadows the plugin, so there is no +# user-scope override that could drift -> nothing to nudge about. +command -v pipefy-mcp-server >/dev/null 2>&1 || exit 0 + +# Cheap checks first; only spawn the binary (Python cold start) once we have a +# plugin version to compare against. +manifest="${CLAUDE_PLUGIN_ROOT:-}/.claude-plugin/plugin.json" +[ -f "$manifest" ] || exit 0 +plugin=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest" | head -n1) +[ -n "$plugin" ] || exit 0 + +installed=$(pipefy-mcp-server --version 2>/dev/null | tr -d '[:space:]') || exit 0 +[ -n "$installed" ] || exit 0 + +if [ "$installed" != "$plugin" ]; then + echo "Pipefy: the installed MCP server ($installed) differs from this plugin ($plugin); re-run /pipefy:install to sync." +fi +exit 0 diff --git a/install.sh b/install.sh index 0da5ac53..83645831 100755 --- a/install.sh +++ b/install.sh @@ -4,7 +4,8 @@ # Resolves the latest GitHub Release tag (or a tag passed via --version), # discovers its wheel assets, installs the CLI and MCP server via # `uv tool install`, optionally installs skills via `npx skills add`, and -# writes the MCP server registration into the chosen client's config. +# registers the MCP server with the chosen client (a config-file write for +# most clients; `claude mcp add` at user scope for Claude Code). set -eu @@ -71,8 +72,11 @@ and register the MCP server with an MCP client. Options: --yes, -y Skip all confirmation prompts. --no-skills Skip the skills installation step. - --client Register MCP server in this client's config. + --client Register the MCP server for this client. One of: claude-code, claude-desktop, cursor, codex, none. + claude-code registers via 'claude mcp add' at user scope + (overrides the plugin's bundled server); the others write + the client's own config file. Defaults to 'none' (prints snippet to paste). --version Install a specific GitHub Release tag (e.g. v0.2.0-beta.2). Defaults to the most recent release (incl. prereleases). @@ -314,6 +318,28 @@ require_python3() { fi } +require_claude() { + if ! command -v claude >/dev/null 2>&1; then + err "the 'claude' CLI is required to register the MCP server in Claude Code, but was not found. Install Claude Code (https://claude.com/claude-code), or re-run with --client none and paste the snippet manually." + fi +} + +claude_code_register_pipefy() { + # Register at USER scope: Claude Code resolves same-named servers + # local > project > user > plugin, so a user-scope `pipefy` shadows the + # plugin's bundled entry and only the binary installed above spawns. The + # bare binary (not a uvx command) keeps the server on the system Python it + # was installed with, without baking an interpreter path into user config. + if [ "$DRY_RUN" -eq 0 ]; then + require_claude + # `claude mcp add` errors on a duplicate name; drop any prior user-scope + # entry first to keep re-runs idempotent. + claude mcp remove pipefy --scope user >/dev/null 2>&1 || true + fi + run claude mcp add pipefy --scope user -- pipefy-mcp-server + [ "$DRY_RUN" -eq 1 ] || say "Registered 'pipefy' at user scope (overrides the plugin's bundled server)." +} + json_merge_pipefy() { path="$1" if [ "$DRY_RUN" -eq 1 ]; then @@ -404,6 +430,8 @@ EOF } write_client_config() { + # cursor/claude-desktop/codex write the client's own config file; claude-code + # instead registers via `claude mcp add` (user scope) to override the plugin. case "$CLIENT" in cursor) json_merge_pipefy "$HOME/.cursor/mcp.json" @@ -415,11 +443,7 @@ write_client_config() { codex_append_pipefy "$HOME/.codex/config.toml" ;; claude-code) - cat < Pipefy MCP server registered at user scope (overrides the plugin's" + say " bundled server). Reload or restart Claude Code for it to take effect." + say " For skills and slash commands, also install the plugin:" + say " /plugin marketplace add $REPO && /plugin install pipefy@pipefy" + fi say "" say "Next: authenticate with Pipefy." say " Default (browser): pipefy auth login" From 8965e24303d1fb872422878d937de2bb08759d5e Mon Sep 17 00:00:00 2001 From: Gabriel Date: Wed, 10 Jun 2026 13:12:28 -0300 Subject: [PATCH 2/3] fix(release): track plugin.json version in the lockstep bump and verify The SessionStart hook (hooks/check-server-version.sh) raw-string compares the installed server's --version against plugin.json's version, but bump_version.py never wrote or verified that file. Releases left the manifest behind, so users on the current release got a drift nudge every session. Add .claude-plugin/ plugin.json to the write and verify paths and sync it to the package version. --- .claude-plugin/plugin.json | 2 +- scripts/bump_version.py | 37 ++++++++++++++++++++++++++++++++++++- tests/test_bump_version.py | 19 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7300fee7..769f0d3d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "pipefy", "displayName": "Pipefy", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "description": "Pipefy workflow integration for Claude Code.", "author": { "name": "Gabriel Custodio" }, "homepage": "https://github.com/pipefy/ai-toolkit", diff --git a/scripts/bump_version.py b/scripts/bump_version.py index e6b7ebcd..6c92e061 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Bump the lockstep workspace version across SDK, MCP, CLI, Auth, Infra, and root workspace meta. +"""Bump the lockstep workspace version across SDK, MCP, CLI, Auth, Infra, the Claude plugin manifest, and root workspace meta. After rewriting the version strings, runs ``uv lock`` so the workspace lockfile's ``pipefy-workspace`` entry tracks the new version. @@ -20,6 +20,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] ROOT_PYPROJECT = REPO_ROOT / "pyproject.toml" +PLUGIN_MANIFEST = REPO_ROOT / ".claude-plugin/plugin.json" INIT_PATHS = ( REPO_ROOT / "packages/sdk/src/pipefy_sdk/__init__.py", REPO_ROOT / "packages/mcp/src/pipefy_mcp/__init__.py", @@ -45,6 +46,15 @@ re.MULTILINE, ) +# The Claude plugin manifest carries the release version too. The SessionStart +# hook (hooks/check-server-version.sh) does a raw-string compare of the +# installed server's --version against this field, so it must move with every +# bump or the hook nudges users who are already on the installed release. +# plugin.json has a single top-level "version" key. +PLUGIN_MANIFEST_VERSION_RE = re.compile( + r'(?P"version"\s*:\s*")(?P[^"]+)(?P")', +) + CORE_VER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)") PRERELEASE_SUFFIX_RE = re.compile( r"^(a|alpha|b|beta|rc)\.?(\d+)$", @@ -90,6 +100,20 @@ def write_version_to_all_files(new_version: str) -> None: raise ValueError(msg) ROOT_PYPROJECT.write_text(new_root, encoding="utf-8") + manifest_text = PLUGIN_MANIFEST.read_text(encoding="utf-8") + new_manifest, manifest_count = PLUGIN_MANIFEST_VERSION_RE.subn( + rf"\g{new_version}\g", + manifest_text, + count=1, + ) + if manifest_count != 1: + msg = ( + f'Expected one "version" key in {PLUGIN_MANIFEST}, ' + f"replaced {manifest_count}" + ) + raise ValueError(msg) + PLUGIN_MANIFEST.write_text(new_manifest, encoding="utf-8") + def parse_core(version: str) -> tuple[int, int, int]: """Parse leading ``X.Y.Z`` from a version string.""" @@ -244,6 +268,17 @@ def verify_lockstep() -> int: ) return 1 + try: + manifest_text = PLUGIN_MANIFEST.read_text(encoding="utf-8") + except OSError as exc: + print(f"could not read {PLUGIN_MANIFEST}: {exc}", file=sys.stderr) + return 1 + m = PLUGIN_MANIFEST_VERSION_RE.search(manifest_text) + if not m: + print(f'missing "version" in {PLUGIN_MANIFEST}', file=sys.stderr) + return 1 + raw[str(PLUGIN_MANIFEST.relative_to(REPO_ROOT))] = m.group("value") + canonical = {label: str(Version(v)) for label, v in raw.items()} if len(set(canonical.values())) != 1: print(f"version mismatch across packages: {raw}", file=sys.stderr) diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py index e7043ad9..ec4665e5 100644 --- a/tests/test_bump_version.py +++ b/tests/test_bump_version.py @@ -85,3 +85,22 @@ def test_root_project_version_re_rejects_missing_version(pyproject: str) -> None r'\1"REPLACED"', pyproject, count=1 ) assert count == 0, f"expected no match in {pyproject!r}" + + +def test_plugin_manifest_version_re_replaces_only_the_version() -> None: + manifest = ( + "{\n" + ' "name": "pipefy",\n' + ' "version": "0.2.0-beta.1",\n' + ' "homepage": "https://github.com/pipefy/ai-toolkit"\n' + "}\n" + ) + new_text, count = _bump.PLUGIN_MANIFEST_VERSION_RE.subn( + r"\g0.2.0-beta.2\g", manifest, count=1 + ) + assert count == 1 + assert '"version": "0.2.0-beta.2"' in new_text + # The named value group exposes the current version for verify mode. + assert _bump.PLUGIN_MANIFEST_VERSION_RE.search(manifest).group("value") == ( + "0.2.0-beta.1" + ) From 2e174b5ee5853ae5438a684207cb873a53f8a198 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Wed, 10 Jun 2026 13:16:04 -0300 Subject: [PATCH 3/3] refactor(release): align plugin manifest regex with sibling version patterns Use numbered groups and re-emit the closing quote in the replacement, matching VERSION_ASSIGN_RE and ROOT_PROJECT_VERSION_RE in the same file, instead of the named-group prefix/value/suffix form. Verify reads the version via group(2). --- scripts/bump_version.py | 6 +++--- tests/test_bump_version.py | 8 +++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/scripts/bump_version.py b/scripts/bump_version.py index 6c92e061..035cd448 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -52,7 +52,7 @@ # bump or the hook nudges users who are already on the installed release. # plugin.json has a single top-level "version" key. PLUGIN_MANIFEST_VERSION_RE = re.compile( - r'(?P"version"\s*:\s*")(?P[^"]+)(?P")', + r'("version"\s*:\s*)"([^"]+)"', ) CORE_VER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)") @@ -102,7 +102,7 @@ def write_version_to_all_files(new_version: str) -> None: manifest_text = PLUGIN_MANIFEST.read_text(encoding="utf-8") new_manifest, manifest_count = PLUGIN_MANIFEST_VERSION_RE.subn( - rf"\g{new_version}\g", + rf'\1"{new_version}"', manifest_text, count=1, ) @@ -277,7 +277,7 @@ def verify_lockstep() -> int: if not m: print(f'missing "version" in {PLUGIN_MANIFEST}', file=sys.stderr) return 1 - raw[str(PLUGIN_MANIFEST.relative_to(REPO_ROOT))] = m.group("value") + raw[str(PLUGIN_MANIFEST.relative_to(REPO_ROOT))] = m.group(2) canonical = {label: str(Version(v)) for label, v in raw.items()} if len(set(canonical.values())) != 1: diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py index ec4665e5..5423845a 100644 --- a/tests/test_bump_version.py +++ b/tests/test_bump_version.py @@ -96,11 +96,9 @@ def test_plugin_manifest_version_re_replaces_only_the_version() -> None: "}\n" ) new_text, count = _bump.PLUGIN_MANIFEST_VERSION_RE.subn( - r"\g0.2.0-beta.2\g", manifest, count=1 + r'\1"0.2.0-beta.2"', manifest, count=1 ) assert count == 1 assert '"version": "0.2.0-beta.2"' in new_text - # The named value group exposes the current version for verify mode. - assert _bump.PLUGIN_MANIFEST_VERSION_RE.search(manifest).group("value") == ( - "0.2.0-beta.1" - ) + # group(2) is the current version that verify mode reads back. + assert _bump.PLUGIN_MANIFEST_VERSION_RE.search(manifest).group(2) == "0.2.0-beta.1"