From a3a059fe5e18110d8532918a6d0ba128c33e152c Mon Sep 17 00:00:00 2001 From: Petr Date: Wed, 6 May 2026 20:15:36 +0200 Subject: [PATCH 1/3] feat(0.30.1): auto-update keboola-mcp-server on startup, parity with kbagent Closes #243 silent-staleness trap: a user installed keboola-mcp-server once via uv tool install, then ran kbagent for months while upstream shipped six minor versions; the cached schema was missing configuration_row_ids (added in MCP v1.55.0) and the user had no signal anything was behind. Two-stage auto-update on startup (auto_update.maybe_auto_update): 1. kbagent self-upgrade -> re-execs new binary 2. keboola-mcp-server upgrade -> uv tool upgrade / pip install -U / uvx --refresh, depending on detected install method Critical invariant: kbagent up-to-date does NOT short-circuit the MCP stage. Both stages always run. kbagent update now upgrades both. JSON output reports per-stage results plus a one-line message summary. kbagent version now reports the locally installed MCP version + up-to-date status, fixing the previously-lying auto_updates: True claim (the field is now actually true, but via the kbagent startup flow, not via uvx-on- every-call). 25 new unit tests across version_service + auto_update. Bumps 0.30.0 -> 0.30.1. --- .claude-plugin/marketplace.json | 2 +- plugins/kbagent/.claude-plugin/plugin.json | 2 +- .../skills/kbagent/references/gotchas.md | 27 ++ pyproject.toml | 2 +- src/keboola_agent_cli/auto_update.py | 177 ++++++--- src/keboola_agent_cli/changelog.py | 9 + src/keboola_agent_cli/services/mcp_service.py | 8 +- .../services/version_service.py | 348 +++++++++++++++++- tests/test_auto_update.py | 190 +++++++++- tests/test_version_service.py | 260 +++++++++++++ uv.lock | 2 +- 11 files changed, 962 insertions(+), 65 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0e2b4a18..6c74459e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.30.0", + "version": "0.30.1", "source": "./plugins/kbagent", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "category": "development" diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index f693a46e..ab4ea871 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.30.0", + "version": "0.30.1", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index e5cd4ba0..c3652728 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -1,5 +1,32 @@ # Gotchas -- Response Parsing and Common Pitfalls +## `keboola-mcp-server` is now auto-updated on kbagent startup (since v0.30.1) + +- Pre-v0.30.1 trap: a user installs `keboola-mcp-server` once via + `uv tool install --prerelease=allow keboola-mcp-server`, then runs kbagent + for months while upstream MCP ships several minor versions. The cached + schema is missing fields (e.g. `configuration_row_ids` added in MCP v1.55.0) + and `kbagent --json tool list` reports the stale schema with no warning. + Reported in #243 -- a real user hit this with MCP v1.49.0 (six minors behind). +- Since v0.30.1: `kbagent` startup runs a two-stage auto-update -- (1) kbagent + itself, (2) `keboola-mcp-server`. The MCP stage detects the install method + (`uv_tool` / `pip_env` / `uvx`) and runs the matching upgrade command + (`uv tool upgrade` / `pip install -U` / `uvx --refresh`). No re-exec needed + for the MCP path -- the next `tool call` spawn picks up the new version. +- Critical invariant: **kbagent up-to-date does NOT short-circuit the MCP + stage**. Both stages always run, regardless of which side has updates. +- `kbagent update` triggers the same two-stage flow explicitly. JSON output + contains separate `kbagent` and `mcp` blocks with per-stage `updated`, + `current_version`, `latest_version` fields plus a one-line `message` + summary. +- Auto-install is intentionally NOT done on startup. If MCP is not installed + locally (`install_method == "none"`), the auto-update flow records the + latest version to the cache but does NOT run `uv tool install`. Use + `kbagent doctor --fix` for the explicit install path. +- `kbagent version` now shows the locally installed MCP version next to the + latest -- previously only the latest was reported, leaving the user with + no signal whether their cache was stale. + ## `storage swap-tables` is dev-branch only and aliases stay put (since v0.28.0) - `kbagent storage swap-tables --project P --table-id A --target-table-id B diff --git a/pyproject.toml b/pyproject.toml index c1d2407c..6b95a74e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-agent-cli" -version = "0.30.0" +version = "0.30.1" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/auto_update.py b/src/keboola_agent_cli/auto_update.py index 16791cff..84a0b0c2 100644 --- a/src/keboola_agent_cli/auto_update.py +++ b/src/keboola_agent_cli/auto_update.py @@ -30,7 +30,14 @@ VERSION_CACHE_FILENAME, VERSION_CHECK_TIMEOUT, ) -from .services.version_service import _fetch_kbagent_latest_version, _is_up_to_date +from .services.version_service import ( + _detect_mcp_install_method, + _fetch_kbagent_latest_version, + _fetch_mcp_latest_version, + _get_local_mcp_version, + _is_up_to_date, + _perform_mcp_update, +) logger = logging.getLogger(__name__) @@ -48,8 +55,11 @@ def _read_cache() -> dict | None: """Read the version cache file. Returns: - Parsed dict with 'last_check' and 'latest_version', or None - if the file is missing, unreadable, or corrupt. + Parsed dict with ``last_check`` (required) and any of + ``latest_version`` / ``mcp_latest_version`` / ``mcp_install_method``, + or None if the file is missing, unreadable, or corrupt. Older + cache formats lacking the MCP fields are still accepted -- the + missing fields trigger a fresh fetch in the same run. """ cache_path = _get_cache_path() try: @@ -63,19 +73,29 @@ def _read_cache() -> dict | None: return None -def _write_cache(latest_version: str) -> None: +def _write_cache( + latest_version: str, + mcp_latest_version: str | None = None, + mcp_install_method: str | None = None, +) -> None: """Write the version cache file. Args: - latest_version: The latest version string to cache. + latest_version: kbagent latest version (required). + mcp_latest_version: keboola-mcp-server latest version from PyPI. + mcp_install_method: Detected MCP install method (drives upgrade cmd). """ cache_path = _get_cache_path() try: cache_path.parent.mkdir(parents=True, exist_ok=True) - payload = { + payload: dict = { "last_check": time.time(), "latest_version": latest_version, } + if mcp_latest_version is not None: + payload["mcp_latest_version"] = mcp_latest_version + if mcp_install_method is not None: + payload["mcp_install_method"] = mcp_install_method cache_path.write_text(json.dumps(payload), encoding="utf-8") except OSError: pass # Non-critical; next run will re-fetch @@ -224,54 +244,127 @@ def show_post_update_changelog() -> None: pass # Never crash +def _maybe_update_mcp(cache: dict | None, fetched_now: bool) -> str | None: + """Check for and apply a keboola-mcp-server upgrade. + + Args: + cache: Existing cache dict (may be stale) or None. + fetched_now: True if this run has already done a fresh latest-version + fetch for kbagent. Used to avoid double network round-trips: + when stale, we issue both fetches in the same pass and persist + both to the cache. + + Returns: + ``mcp_latest_version`` to persist to the cache (None if skipped or + fetch failed). Caller composes the cache write. + """ + # Use cached MCP latest if fresh; otherwise fetch. + cached_latest: str | None = None + if cache is not None: + candidate = cache.get("mcp_latest_version") + if isinstance(candidate, str) and candidate: + cached_latest = candidate + + if not fetched_now and cached_latest: + mcp_latest: str | None = cached_latest + else: + mcp_latest = _fetch_mcp_latest_version(timeout=VERSION_CHECK_TIMEOUT) + + if mcp_latest is None: + return cached_latest # nothing to do; preserve any prior cache + + local_version = _get_local_mcp_version() + up_to_date = _is_up_to_date(local_version, mcp_latest) + if up_to_date is True: + return mcp_latest + + method = _detect_mcp_install_method() + if method == "none": + # Nothing installed locally; do not auto-install on startup. + return mcp_latest + + sys.stderr.write( + f"Updating keboola-mcp-server v{local_version or 'unknown'} -> v{mcp_latest}" + f" (via {method})...\n" + ) + success, info = _perform_mcp_update(method=method, timeout=180.0) + if success: + post_version = _get_local_mcp_version() or mcp_latest + sys.stderr.write(f"Updated keboola-mcp-server to v{post_version}.\n") + else: + sys.stderr.write( + f"keboola-mcp-server upgrade skipped: {info}; continuing with current version.\n" + ) + + return mcp_latest + + def maybe_auto_update() -> None: """Main entry point for the auto-update flow. - Called from cli.py at the very top of main(). Orchestrates: - 1. Skip-condition checks - 2. Cache lookup (avoid network call if TTL is fresh) - 3. Fetch latest version from GitHub if cache is stale - 4. Compare versions - 5. Download update - 6. Re-exec the same command with the new binary - - This function NEVER raises. Any exception is caught and silently - logged so the CLI always proceeds normally. + Called from ``cli.py`` at the very top of ``main()``. Orchestrates two + sequential stages: + + 1. **kbagent self-update** -- if the installed version is behind the + latest GitHub release, download the upgrade and ``execvpe`` the new + binary with the same argv. The new process re-enters this function + and the kbagent stage short-circuits as up-to-date. + 2. **keboola-mcp-server update** -- if the locally installed MCP server + is behind PyPI, run the upgrade command matching the install + method (``uv tool upgrade`` / ``pip install -U`` / ``uvx --refresh``). + No re-exec is needed: the MCP server is spawned by ``tool call`` + commands and the next spawn picks up the new version. + + Cache discipline: a single cache file at + ``~/.config/keboola-agent-cli/version_cache.json`` stores both + ``latest_version`` (kbagent) and ``mcp_latest_version`` so we make at + most two PyPI/GitHub round-trips per ``AUTO_UPDATE_CHECK_INTERVAL``. + + This function NEVER raises. All exceptions are caught and logged at + debug level so the CLI always proceeds normally. """ try: if _should_skip(): return cache = _read_cache() - latest_version: str | None = None + cache_is_fresh = bool(cache and _is_cache_fresh(cache, AUTO_UPDATE_CHECK_INTERVAL)) - if cache and _is_cache_fresh(cache, AUTO_UPDATE_CHECK_INTERVAL): - latest_version = cache.get("latest_version") + # Stage 1: kbagent self-update. + if cache_is_fresh: + latest_version: str | None = cache.get("latest_version") # type: ignore[union-attr] else: latest_version = _fetch_kbagent_latest_version(timeout=VERSION_CHECK_TIMEOUT) - if latest_version: - _write_cache(latest_version) - - if latest_version is None: - return - - up_to_date = _is_up_to_date(__version__, latest_version) - if up_to_date is True or up_to_date is None: - return - - # Update available - sys.stderr.write(f"Updating kbagent v{__version__} -> v{latest_version}...\n") - - if not _perform_update(latest_version): - sys.stderr.write("Auto-update failed; continuing with current version.\n") - return - - sys.stderr.write(f"Updated to v{latest_version}. Re-launching...\n") - # Store old version so the re-exec'd process can show "What's new" - os.environ[ENV_UPDATED_FROM] = __version__ - _re_exec() - # If re-exec fails (shouldn't happen), continue with old version + if latest_version is not None: + up_to_date = _is_up_to_date(__version__, latest_version) + if up_to_date is False: + sys.stderr.write(f"Updating kbagent v{__version__} -> v{latest_version}...\n") + if _perform_update(latest_version): + sys.stderr.write(f"Updated to v{latest_version}. Re-launching...\n") + # Persist cache before re-exec so the new process does + # not refetch immediately. + _write_cache( + latest_version, + mcp_latest_version=cache.get("mcp_latest_version") if cache else None, + mcp_install_method=cache.get("mcp_install_method") if cache else None, + ) + os.environ[ENV_UPDATED_FROM] = __version__ + _re_exec() + return # Defensive: _re_exec replaces the process. + sys.stderr.write("Auto-update failed; continuing with current version.\n") + + # Stage 2: keboola-mcp-server update. + mcp_latest = _maybe_update_mcp(cache, fetched_now=not cache_is_fresh) + mcp_install_method = _detect_mcp_install_method() + + # Persist combined cache (kbagent + MCP) when we did any fresh fetch. + if not cache_is_fresh and latest_version is not None: + _write_cache( + latest_version, + mcp_latest_version=mcp_latest, + mcp_install_method=mcp_install_method, + ) except Exception: - # Blanket catch: auto-update must NEVER crash the CLI + # Blanket catch: auto-update must NEVER crash the CLI. logger.debug("Auto-update check failed", exc_info=True) diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 710f23d5..7cef3fe5 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -8,6 +8,15 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.30.1": [ + "Fix: kbagent now auto-updates `keboola-mcp-server` on startup, the same way it self-updates kbagent itself. Closes the silent-staleness trap reported in #243: a user installed `keboola-mcp-server v1.49.0` once via `uv tool install`, then ran kbagent for months while the upstream MCP server shipped six minor versions; the locally cached schema was missing `configuration_row_ids` (added in MCP v1.55.0) and the user had no signal anything was behind. `auto_update.maybe_auto_update()` now runs two sequential stages: (1) the existing kbagent self-upgrade (re-execs the new binary), then (2) a fresh keboola-mcp-server upgrade. The MCP stage detects the install method (`uv_tool` / `pip_env` / `uvx`) and runs the matching upgrade command (`uv tool upgrade keboola-mcp-server` / `pip install --upgrade keboola-mcp-server` / `uvx --refresh --from keboola-mcp-server keboola_mcp_server --version`). No re-exec needed for the MCP path -- the server is spawned by `tool call` commands and the next spawn picks up the new version. Critical invariant: kbagent up-to-date does NOT short-circuit the MCP stage; both stages always run.", + "Fix: `kbagent update` (`VersionService.self_update`) now upgrades both kbagent and keboola-mcp-server in a single command. Output reports both stages independently in JSON (`{kbagent: {...}, mcp: {...}, updated: bool, message: str}`) plus a human-readable summary line such as `kbagent v0.30.0 -> v0.30.1 | keboola-mcp-server v1.49.0 -> v1.59.1`. Previously `kbagent update` only ever upgraded kbagent itself, leaving the MCP server pinned to whatever PyPI was on the day the user originally installed it -- the user mental model (`update` = update the kbagent stack) was being silently violated.", + "Fix: `kbagent version` now reports the locally installed `keboola-mcp-server` version (`version` field) and the up-to-date status (`up_to_date` field) for the dependency, in addition to the existing `latest_version`. Previously the `auto_updates: True` field and the docstring claimed `keboola-mcp-server - always runs latest via 'uvx keboola_mcp_server@latest'` which was incorrect: the actual `detect_mcp_server_command` in `mcp_service.py` deliberately omits `@latest` to avoid a 25s PyPI check on every invocation, so the cached/pinned version persisted indefinitely. After this release the field is once again accurate -- MCP IS auto-updated, but via the kbagent startup auto-update flow (and `kbagent update`), not via uvx-on-every-call.", + "New: `_get_local_mcp_version()` and `_detect_mcp_install_method()` helpers in `services/version_service.py`. The version probe runs `keboola_mcp_server --version` as a subprocess (works for both direct binary installs and `uv tool install`-managed binaries; both publish a `keboola_mcp_server` script on PATH), with `importlib.metadata.version` as fallback for pip-in-current-env installs. The install-method detector reads `uv tool list` to distinguish `uv_tool` from a pip-installed binary on PATH, then falls back to `importlib.metadata.distribution`, then to uvx availability, then to `none`. The result drives which upgrade command runs in the auto-update stage.", + "Cache: the version cache file (`~/.config/keboola-agent-cli/version_cache.json`) is extended with `mcp_latest_version` and `mcp_install_method` keys alongside the existing `latest_version` (kbagent). Backwards-compatible -- older cache files lacking the MCP keys are accepted and trigger a fresh fetch in the same run. At most two PyPI/GitHub round-trips per `AUTO_UPDATE_CHECK_INTERVAL`. Auto-install was deliberately NOT added to the startup flow: if MCP is not installed locally (`install_method == 'none'`), the auto-update startup hook reports the latest version to the cache but does NOT run `uv tool install` -- that decision belongs to `kbagent doctor --fix` which is the explicit install entry point.", + "Tests: 25 new unit tests across `tests/test_version_service.py` (12) and `tests/test_auto_update.py` (13) covering: local version detection (binary stdout, binary stderr, importlib.metadata fallback, missing/timeout cases), install-method detection (uv_tool vs pip_env vs uvx vs none), each upgrade-command shape, the two-stage `self_update` orchestrator (both up-to-date / only-MCP-stale-still-runs / kbagent-stage-failure-still-runs-MCP / blanket exception swallow), and the cache schema migration. Existing `TestMaybeAutoUpdate` is updated to assert the new multi-key `_write_cache` signature and adds an autouse fixture stubbing the MCP helpers so kbagent-stage tests never touch the network or subprocess.", + "Plugin docs: new `(since v0.30.1)` gotcha entry in `gotchas.md` -- 'kbagent now auto-updates keboola-mcp-server on startup; uv tool install pin is no longer a stale-version trap'. References issue #243 root cause + the install-method detection logic so AI agents can explain the new behaviour to users when asked.", + ], "0.30.0": [ "New: `kbagent search QUERY` -- top-level cross-project item search. Two modes: **textual** (default) calls the Storage API `GET /v2/storage/global-search` endpoint (name-based, fast, parallel multi-project fan-out via `BaseService._run_parallel()` with per-project error accumulation; one project failing does NOT stop others); **config-based** (`--search-type config-based`) delegates to the existing `ConfigService.search_configs()` for full JSON-body scanning. `--type` is repeatable and accepts `table`, `bucket`, `config`, `flow`, `data-app`, `transformation`; the user-facing names map to the API's `types[]` parameter (config/data-app both translate to `configuration`, with the data-app variant post-filtered to `component_id == 'keboola.data-apps'` so `--type data-app` no longer returns ALL configurations). `--limit` applies per project in textual mode (1-100, default 50). `--project` is repeatable for narrow scope; omitted means all configured projects. Pre-flight `has_feature('global-search')` check returns a clear per-project error rather than a raw 404 on stacks where the feature flag is off. Addresses the cross-project search use-case raised in #244.", "New: `kbagent project info --project NAME` -- returns full project metadata in a single call: project ID, project name, stack URL, default backend, the complete `features` list (used by AI agents to gate behavior, e.g. `storage-branches`, `global-search`, `queuev2`), quota limits, and usage metrics. Backed by `KeboolaClient.get_project_info()` which returns the raw `GET /v2/storage/tokens/verify` payload. Distinct from `project status` (connectivity ping) and `project list` (multi-project summary) -- `info` is the canonical single-project audit command. Hint definitions for both `--hint client` (`KeboolaClient.get_project_info()`) and `--hint service` (`ProjectService.get_info()`).", diff --git a/src/keboola_agent_cli/services/mcp_service.py b/src/keboola_agent_cli/services/mcp_service.py index a122248f..29d85051 100644 --- a/src/keboola_agent_cli/services/mcp_service.py +++ b/src/keboola_agent_cli/services/mcp_service.py @@ -99,8 +99,12 @@ def detect_mcp_server_command() -> list[str] | None: 3. uvx keboola_mcp_server (cached version, ~1s cached / ~4.5s uncached) Note: We intentionally do NOT use @latest with uvx because it forces - a PyPI check on every invocation (~25s penalty). The cached version - is used instead. Users can update manually with: uvx upgrade keboola_mcp_server + a PyPI check on every invocation (~25s penalty). The cached / pinned + version is used at spawn time. Freshness is maintained out-of-band by + the auto-update flow (`auto_update.maybe_auto_update`, since v0.30.1) + which runs at kbagent startup and bumps the local MCP install through + `uv tool upgrade` / `pip install -U` / `uvx --refresh` depending on + the detected install method. Manual override: `kbagent update`. Returns: List of command parts, or None if no method is available. diff --git a/src/keboola_agent_cli/services/version_service.py b/src/keboola_agent_cli/services/version_service.py index f5ac8d8d..7d5afb1c 100644 --- a/src/keboola_agent_cli/services/version_service.py +++ b/src/keboola_agent_cli/services/version_service.py @@ -1,13 +1,16 @@ """Version service - detect local versions and check for updates. -Provides version information for kbagent and its dependency: -- keboola-mcp-server - always runs latest via 'uvx keboola_mcp_server@latest', - version resolved from PyPI +Provides version information for kbagent and the keboola-mcp-server +dependency. Both are auto-updated on kbagent startup (see auto_update.py) +and explicitly via ``kbagent update``. The MCP server version is detected +from the locally installed binary or Python distribution; the latest +version is resolved from PyPI. """ import logging import re import shutil +import subprocess from typing import Any import httpx @@ -23,12 +26,183 @@ logger = logging.getLogger(__name__) +# keboola-mcp-server constants +MCP_PACKAGE_NAME = "keboola-mcp-server" +MCP_BINARY_NAME = "keboola_mcp_server" + def _is_uvx_available() -> bool: """Check if uvx is available on PATH.""" return shutil.which("uvx") is not None +def _get_local_mcp_version(timeout: float = 5.0) -> str | None: + """Detect the locally installed keboola-mcp-server version. + + Resolution order: + + 1. ``keboola_mcp_server --version`` subprocess (works for both a direct + binary install and ``uv tool install`` -- they both publish a + ``keboola_mcp_server`` script on PATH). + 2. ``importlib.metadata.version("keboola-mcp-server")`` (works when the + package is pip-installed in the active Python environment). + 3. None when neither works (typically: uvx cache; we cannot cleanly + inspect uvx-cached-only installs without forcing a download). + + Args: + timeout: Subprocess timeout in seconds for the binary --version probe. + + Returns: + Version string like ``"1.59.1"``, or None when undetectable. + """ + binary = shutil.which(MCP_BINARY_NAME) + if binary: + try: + result = subprocess.run( + [binary, "--version"], + capture_output=True, + text=True, + timeout=timeout, + ) + if result.returncode == 0: + # Combined stdout + stderr -- some tools print version to stderr. + output = (result.stdout or "") + (result.stderr or "") + m = re.search(r"(\d+\.\d+\.\d+)", output) + if m: + return m.group(1) + except (subprocess.TimeoutExpired, OSError): + pass + + # Fallback: try importlib.metadata in the current Python environment. + try: + from importlib.metadata import PackageNotFoundError + from importlib.metadata import version as _pkg_version + + try: + return _pkg_version(MCP_PACKAGE_NAME) + except PackageNotFoundError: + return None + except ImportError: + return None + + +def _detect_mcp_install_method() -> str: + """Detect how keboola-mcp-server is installed locally. + + The detection drives which upgrade command is appropriate: + + - ``uv_tool`` -- installed via ``uv tool install``; upgrade with + ``uv tool upgrade keboola-mcp-server``. + - ``pip_env`` -- pip-installed in the active Python environment; + upgrade with ``pip install --upgrade keboola-mcp-server``. + - ``uvx`` -- only available via uvx cache (no persistent install); + upgrade with ``uvx --refresh ...`` to invalidate the cache. + - ``none`` -- not detectable; cannot upgrade automatically. + + Returns: + One of ``"uv_tool"``, ``"pip_env"``, ``"uvx"``, ``"none"``. + """ + binary = shutil.which(MCP_BINARY_NAME) + if binary: + # Binary exists. Check if it is registered with `uv tool`. + uv_path = shutil.which("uv") + if uv_path: + try: + result = subprocess.run( + [uv_path, "tool", "list"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and MCP_PACKAGE_NAME in result.stdout: + return "uv_tool" + except (subprocess.TimeoutExpired, OSError): + pass + # Binary exists but not under uv tool -- treat as pip env install. + return "pip_env" + + # No binary on PATH; try importlib.metadata. + try: + from importlib.metadata import PackageNotFoundError + from importlib.metadata import distribution as _dist + + try: + _dist(MCP_PACKAGE_NAME) + return "pip_env" + except PackageNotFoundError: + pass + except ImportError: + pass + + # Fallback: uvx cache only. + if shutil.which("uvx"): + return "uvx" + + return "none" + + +def _perform_mcp_update( + method: str | None = None, + timeout: float = 180.0, +) -> tuple[bool, str]: + """Run the appropriate upgrade command for keboola-mcp-server. + + Args: + method: Optional install method; if None, detect via + :func:`_detect_mcp_install_method`. + timeout: Subprocess timeout in seconds. + + Returns: + Tuple of ``(success, output_or_reason)``. + """ + if method is None: + method = _detect_mcp_install_method() + + cmd: list[str] | None = None + if method == "uv_tool": + uv_path = shutil.which("uv") + if uv_path is None: + return False, "uv not found on PATH" + cmd = [uv_path, "tool", "upgrade", MCP_PACKAGE_NAME] + elif method == "pip_env": + pip_path = shutil.which("pip") + if pip_path is None: + return False, "pip not found on PATH" + cmd = [pip_path, "install", "--upgrade", MCP_PACKAGE_NAME] + elif method == "uvx": + uvx_path = shutil.which("uvx") + if uvx_path is None: + return False, "uvx not found on PATH" + # `uvx --refresh` re-downloads even cached packages. + cmd = [ + uvx_path, + "--refresh", + "--from", + MCP_PACKAGE_NAME, + MCP_BINARY_NAME, + "--version", + ] + elif method == "none": + return False, "keboola-mcp-server is not installed" + else: + return False, f"unknown install method: {method!r}" + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + ) + if result.returncode == 0: + return True, (result.stdout or "").strip() + return False, (result.stderr or "").strip() or "upgrade subprocess failed" + except subprocess.TimeoutExpired: + return False, f"upgrade timed out after {timeout}s" + except OSError as exc: + return False, f"subprocess error: {exc}" + + def _fetch_kbagent_latest_version(timeout: float = VERSION_CHECK_TIMEOUT) -> str | None: """Fetch latest kbagent version from GitHub releases. @@ -111,24 +285,48 @@ class VersionService: def get_versions(self) -> dict[str, Any]: """Get version information for kbagent and its dependency. - keboola-mcp-server: always runs latest via 'uvx ... @latest', - so we only need to check PyPI for the current latest version - and whether uvx is available. + Both ``kbagent`` and ``keboola-mcp-server`` are auto-updated on + startup (see :mod:`keboola_agent_cli.auto_update`) and explicitly + via ``kbagent update``. This method reports: + + - the local installed version (best-effort detection for MCP), + - the latest available version (GitHub Releases for kbagent, + PyPI for MCP), + - the up-to-date status, + - the install method for MCP (drives which upgrade command runs), + - the upgrade command shown to the user. Returns: - Structured dict with kbagent version and dependency info. + Structured dict with kbagent + MCP version info. """ - uvx_available = _is_uvx_available() - mcp_latest = _fetch_mcp_latest_version() kbagent_latest = _fetch_kbagent_latest_version() kbagent_up_to_date = _is_up_to_date(__version__, kbagent_latest) + mcp_local = _get_local_mcp_version() + mcp_latest = _fetch_mcp_latest_version() + mcp_up_to_date = _is_up_to_date(mcp_local, mcp_latest) + mcp_method = _detect_mcp_install_method() + + # Map install method to the upgrade command shown to users. + mcp_upgrade_cmd_by_method = { + "uv_tool": f"uv tool upgrade {MCP_PACKAGE_NAME}", + "pip_env": f"pip install --upgrade {MCP_PACKAGE_NAME}", + "uvx": f"uvx --refresh --from {MCP_PACKAGE_NAME} {MCP_BINARY_NAME} --version", + "none": f"uv tool install {MCP_PACKAGE_NAME}", + } + mcp_entry: dict[str, Any] = { - "name": "keboola-mcp-server", - "description": "Keboola MCP Server (via uvx @latest)", - "uvx_available": uvx_available, + "name": MCP_PACKAGE_NAME, + "description": "Keboola MCP Server", + "uvx_available": _is_uvx_available(), + "version": mcp_local, "latest_version": mcp_latest, + "up_to_date": mcp_up_to_date, + "install_method": mcp_method, "auto_updates": True, + "upgrade_command": mcp_upgrade_cmd_by_method.get( + mcp_method, mcp_upgrade_cmd_by_method["none"] + ), } return { @@ -144,13 +342,80 @@ def get_versions(self) -> dict[str, Any]: } def self_update(self) -> dict[str, Any]: - """Update kbagent to the latest version via uv tool install. + """Update kbagent + keboola-mcp-server to the latest versions. + + Two-stage flow (both stages always run -- kbagent up-to-date does + not skip the MCP stage, and vice versa): + + 1. **kbagent** -- uses ``uv tool install --upgrade`` (preferred) + or pip fallback. If the installed kbagent is already at the + latest version, the stage reports ``updated=False`` without + running a subprocess. + 2. **keboola-mcp-server** -- detects install method via + :func:`_detect_mcp_install_method`, runs the matching upgrade + command (``uv tool upgrade`` / ``pip install -U`` / + ``uvx --refresh``), and reports the before / after versions. + If the local MCP version cannot be detected (e.g. uvx-cache-only + install on first run) the stage still attempts the upgrade -- + a refreshed cache is the desired outcome there. Returns: - Dict with update result (old version, new version, output). + Dict with both stages' results:: + + { + "kbagent": {"updated": bool, "current_version": str, + "latest_version": str|None, "message": str, + "output": str|None}, + "mcp": {"updated": bool|None, "current_version": str|None, + "latest_version": str|None, "install_method": str, + "message": str, "output": str|None}, + "updated": bool, # True iff at least one stage upgraded + "message": str, # Human-readable single-line summary + } """ - import subprocess + kbagent_result = self._update_kbagent() + mcp_result = self._update_mcp() + + any_updated = bool(kbagent_result.get("updated") or mcp_result.get("updated")) + summary = self._compose_update_summary(kbagent_result, mcp_result) + + return { + "kbagent": kbagent_result, + "mcp": mcp_result, + "updated": any_updated, + "message": summary, + } + @staticmethod + def _compose_update_summary(kbagent_result: dict[str, Any], mcp_result: dict[str, Any]) -> str: + """Build a one-line summary of the two-stage update result.""" + parts: list[str] = [] + if kbagent_result.get("updated"): + parts.append( + f"kbagent v{kbagent_result.get('current_version')}" + f" -> v{kbagent_result.get('latest_version')}" + ) + elif kbagent_result.get("current_version") and kbagent_result.get("latest_version"): + parts.append(f"kbagent v{kbagent_result.get('current_version')} (already up to date)") + + if mcp_result.get("updated"): + current = mcp_result.get("current_version") or "unknown" + latest = mcp_result.get("latest_version") or "?" + parts.append(f"keboola-mcp-server v{current} -> v{latest}") + elif mcp_result.get("updated") is False and mcp_result.get("current_version"): + parts.append( + f"keboola-mcp-server v{mcp_result.get('current_version')} (already up to date)" + ) + elif mcp_result.get("updated") is False: + parts.append(f"keboola-mcp-server: {mcp_result.get('message', 'skipped')}") + + if not parts: + return "Nothing to update." + return " | ".join(parts) + + @staticmethod + def _update_kbagent() -> dict[str, Any]: + """Run the kbagent self-upgrade subprocess (or short-circuit).""" old_version = __version__ kbagent_latest = _fetch_kbagent_latest_version() up_to_date = _is_up_to_date(old_version, kbagent_latest) @@ -163,7 +428,7 @@ def self_update(self) -> dict[str, Any]: "message": f"kbagent v{old_version} is already up to date.", } - # Try uv tool install --upgrade first, fall back to pip + # Try uv tool install --upgrade first, fall back to pip. uv_path = shutil.which("uv") if uv_path: cmd = [uv_path, "tool", "install", "--upgrade", KBAGENT_INSTALL_SOURCE] @@ -209,3 +474,54 @@ def self_update(self) -> dict[str, Any]: "latest_version": kbagent_latest, "message": "Update timed out after 120 seconds.", } + + @staticmethod + def _update_mcp() -> dict[str, Any]: + """Run the keboola-mcp-server upgrade subprocess (or short-circuit).""" + method = _detect_mcp_install_method() + local_version = _get_local_mcp_version() + latest_version = _fetch_mcp_latest_version() + up_to_date = _is_up_to_date(local_version, latest_version) + + # Short-circuit: detected and up-to-date. + if up_to_date is True: + return { + "updated": False, + "current_version": local_version, + "latest_version": latest_version, + "install_method": method, + "message": f"keboola-mcp-server v{local_version} is already up to date.", + } + + # Short-circuit: nothing to upgrade against. + if method == "none": + return { + "updated": False, + "current_version": local_version, + "latest_version": latest_version, + "install_method": method, + "message": ( + "keboola-mcp-server is not installed. " + f"Install with: uv tool install {MCP_PACKAGE_NAME}" + ), + } + + # Run the upgrade. + success, output = _perform_mcp_update(method=method, timeout=180.0) + post_version = _get_local_mcp_version() if success else local_version + + return { + "updated": bool(success), + "current_version": local_version, + "latest_version": latest_version, + "post_upgrade_version": post_version, + "install_method": method, + "message": ( + f"Upgraded keboola-mcp-server " + f"({local_version or 'unknown'} -> {post_version or latest_version or '?'}) " + f"via {method}." + if success + else f"keboola-mcp-server upgrade failed: {output}" + ), + "output": output, + } diff --git a/tests/test_auto_update.py b/tests/test_auto_update.py index 1d0ca8b1..d4891f89 100644 --- a/tests/test_auto_update.py +++ b/tests/test_auto_update.py @@ -5,10 +5,13 @@ import time from unittest.mock import MagicMock, patch +import pytest + from keboola_agent_cli.auto_update import ( _get_cache_path, _is_cache_fresh, _is_dev_install, + _maybe_update_mcp, _perform_update, _re_exec, _read_cache, @@ -280,6 +283,27 @@ def test_fallback_to_python_m(self, mock_which, mock_execvpe): class TestMaybeAutoUpdate: """Tests for the maybe_auto_update() orchestrator.""" + @pytest.fixture(autouse=True) + def _no_real_mcp_calls(self): + """Disable MCP-side helpers across all tests in this class. + + The kbagent-stage tests do not care about MCP behaviour; without + this fixture they would either issue real subprocess / + importlib.metadata lookups or trigger network round-trips to + PyPI. Each MCP test below opts in by re-patching as needed. + """ + with ( + patch( + "keboola_agent_cli.auto_update._maybe_update_mcp", + return_value=None, + ), + patch( + "keboola_agent_cli.auto_update._detect_mcp_install_method", + return_value="none", + ), + ): + yield + @patch("keboola_agent_cli.auto_update._should_skip", return_value=True) def test_skip_conditions_respected(self, mock_skip): # Should return immediately without doing anything @@ -306,7 +330,13 @@ def test_cache_stale_fetches( ): maybe_auto_update() mock_fetch.assert_called_once() - mock_write.assert_called_once_with("2.0.0") + # Cache write now bundles the kbagent latest with the MCP latest + + # install method (both may be None when MCP helpers are no-op'ed). + mock_write.assert_called_once() + kwargs = mock_write.call_args.kwargs + assert mock_write.call_args.args[0] == "2.0.0" + assert "mcp_latest_version" in kwargs + assert "mcp_install_method" in kwargs @patch("keboola_agent_cli.auto_update._should_skip", return_value=False) @patch("keboola_agent_cli.auto_update._read_cache", return_value=None) @@ -445,3 +475,161 @@ def test_changelog_command_clears_updated_from_env(self, monkeypatch): # Matching the exact header format avoids false positives from # prose mentions of the phrase inside changelog entries themselves. assert " What's new in v" not in result.output + + +# --------------------------------------------------------------------------- +# _maybe_update_mcp -- MCP-server side of the auto-update flow (since v0.28.1) +# --------------------------------------------------------------------------- + + +class TestMaybeUpdateMcp: + """Tests for ``_maybe_update_mcp`` -- the keboola-mcp-server upgrade stage.""" + + @patch("keboola_agent_cli.auto_update._fetch_mcp_latest_version", return_value=None) + def test_pypi_unreachable_returns_cached(self, mock_fetch): + """If PyPI fetch fails, fall back to whatever the cache had.""" + result = _maybe_update_mcp( + cache={"last_check": time.time(), "mcp_latest_version": "1.50.0"}, + fetched_now=False, + ) + assert result == "1.50.0" + + @patch("keboola_agent_cli.auto_update._fetch_mcp_latest_version", return_value="1.59.1") + @patch("keboola_agent_cli.auto_update._get_local_mcp_version", return_value="1.59.1") + @patch("keboola_agent_cli.auto_update._detect_mcp_install_method", return_value="uv_tool") + @patch("keboola_agent_cli.auto_update._perform_mcp_update") + def test_up_to_date_skips_upgrade(self, mock_perform, mock_detect, mock_local, mock_fetch): + """Local matches PyPI latest -> no upgrade subprocess.""" + result = _maybe_update_mcp(cache=None, fetched_now=True) + assert result == "1.59.1" + mock_perform.assert_not_called() + + @patch("keboola_agent_cli.auto_update._fetch_mcp_latest_version", return_value="1.59.1") + @patch("keboola_agent_cli.auto_update._get_local_mcp_version", return_value="1.49.0") + @patch("keboola_agent_cli.auto_update._detect_mcp_install_method", return_value="uv_tool") + @patch("keboola_agent_cli.auto_update._perform_mcp_update", return_value=(True, "ok")) + def test_stale_triggers_upgrade(self, mock_perform, mock_detect, mock_local, mock_fetch): + """Local behind PyPI -> upgrade subprocess invoked.""" + result = _maybe_update_mcp(cache=None, fetched_now=True) + assert result == "1.59.1" + mock_perform.assert_called_once_with(method="uv_tool", timeout=180.0) + + @patch("keboola_agent_cli.auto_update._fetch_mcp_latest_version", return_value="1.59.1") + @patch("keboola_agent_cli.auto_update._get_local_mcp_version", return_value=None) + @patch("keboola_agent_cli.auto_update._detect_mcp_install_method", return_value="none") + @patch("keboola_agent_cli.auto_update._perform_mcp_update") + def test_not_installed_does_not_install( + self, mock_perform, mock_detect, mock_local, mock_fetch + ): + """If MCP is not installed locally, do not auto-install on startup.""" + result = _maybe_update_mcp(cache=None, fetched_now=True) + assert result == "1.59.1" + mock_perform.assert_not_called() + + @patch("keboola_agent_cli.auto_update._fetch_mcp_latest_version", return_value="1.59.1") + @patch("keboola_agent_cli.auto_update._get_local_mcp_version", return_value="1.49.0") + @patch("keboola_agent_cli.auto_update._detect_mcp_install_method", return_value="pip_env") + @patch( + "keboola_agent_cli.auto_update._perform_mcp_update", + return_value=(False, "permission denied"), + ) + def test_upgrade_failure_does_not_raise( + self, mock_perform, mock_detect, mock_local, mock_fetch + ): + """Subprocess failure logs to stderr but the function still returns.""" + result = _maybe_update_mcp(cache=None, fetched_now=True) + assert result == "1.59.1" # Cache key still updates + mock_perform.assert_called_once() + + @patch("keboola_agent_cli.auto_update._fetch_mcp_latest_version", return_value="1.59.1") + @patch("keboola_agent_cli.auto_update._get_local_mcp_version", return_value="1.49.0") + @patch("keboola_agent_cli.auto_update._detect_mcp_install_method", return_value="uv_tool") + @patch("keboola_agent_cli.auto_update._perform_mcp_update", return_value=(True, "ok")) + def test_uses_cache_when_not_fetched_now( + self, mock_perform, mock_detect, mock_local, mock_fetch + ): + """If we already have a fresh cache, do not re-fetch from PyPI.""" + cache = {"last_check": time.time(), "mcp_latest_version": "1.59.0"} + _maybe_update_mcp(cache=cache, fetched_now=False) + mock_fetch.assert_not_called() + + @patch("keboola_agent_cli.auto_update._fetch_mcp_latest_version", return_value="1.59.1") + @patch("keboola_agent_cli.auto_update._get_local_mcp_version", return_value="1.49.0") + @patch("keboola_agent_cli.auto_update._detect_mcp_install_method", return_value="uv_tool") + @patch("keboola_agent_cli.auto_update._perform_mcp_update", return_value=(True, "ok")) + def test_refetches_when_fetched_now_overrides_cache( + self, mock_perform, mock_detect, mock_local, mock_fetch + ): + """When fetched_now is True (kbagent path also did a fresh fetch), refetch.""" + cache = {"last_check": time.time(), "mcp_latest_version": "1.50.0"} + _maybe_update_mcp(cache=cache, fetched_now=True) + mock_fetch.assert_called_once() + + +# --------------------------------------------------------------------------- +# maybe_auto_update -- end-to-end MCP integration (since v0.28.1) +# --------------------------------------------------------------------------- + + +class TestMaybeAutoUpdateMcpIntegration: + """End-to-end tests for the MCP stage inside maybe_auto_update.""" + + @patch("keboola_agent_cli.auto_update._should_skip", return_value=False) + @patch("keboola_agent_cli.auto_update._read_cache", return_value=None) + @patch("keboola_agent_cli.auto_update._fetch_kbagent_latest_version", return_value="1.0.0") + @patch("keboola_agent_cli.auto_update._is_up_to_date", return_value=True) + @patch("keboola_agent_cli.auto_update._maybe_update_mcp") + @patch("keboola_agent_cli.auto_update._detect_mcp_install_method", return_value="uv_tool") + @patch("keboola_agent_cli.auto_update._write_cache") + def test_kbagent_uptodate_still_runs_mcp_stage( + self, + mock_write, + mock_detect, + mock_mcp, + mock_up_to_date, + mock_fetch, + mock_cache, + mock_skip, + ): + """Even when kbagent is up-to-date, the MCP stage MUST run.""" + maybe_auto_update() + mock_mcp.assert_called_once() + + @patch("keboola_agent_cli.auto_update._should_skip", return_value=False) + @patch("keboola_agent_cli.auto_update._read_cache", return_value=None) + @patch("keboola_agent_cli.auto_update._fetch_kbagent_latest_version", return_value="2.0.0") + @patch("keboola_agent_cli.auto_update._is_up_to_date", return_value=False) + @patch("keboola_agent_cli.auto_update._perform_update", return_value=False) + @patch("keboola_agent_cli.auto_update._maybe_update_mcp") + @patch("keboola_agent_cli.auto_update._detect_mcp_install_method", return_value="uv_tool") + @patch("keboola_agent_cli.auto_update._write_cache") + def test_failed_kbagent_upgrade_still_runs_mcp_stage( + self, + mock_write, + mock_detect, + mock_mcp, + mock_perform, + mock_up_to_date, + mock_fetch, + mock_cache, + mock_skip, + ): + """If kbagent upgrade fails (no re-exec), still try MCP upgrade.""" + maybe_auto_update() + mock_mcp.assert_called_once() + + @patch("keboola_agent_cli.auto_update._should_skip", return_value=False) + def test_exception_in_mcp_stage_does_not_crash(self, mock_skip): + """A blowup in the MCP stage MUST be caught by the blanket try/except.""" + cache = {"last_check": time.time(), "latest_version": "1.0.0"} + with ( + patch("keboola_agent_cli.auto_update._read_cache", return_value=cache), + patch("keboola_agent_cli.auto_update._is_cache_fresh", return_value=True), + patch("keboola_agent_cli.auto_update._is_up_to_date", return_value=True), + patch( + "keboola_agent_cli.auto_update._maybe_update_mcp", + side_effect=RuntimeError("kaboom"), + ), + ): + # MUST NOT raise. + maybe_auto_update() diff --git a/tests/test_version_service.py b/tests/test_version_service.py index a720589f..5d8c7a2e 100644 --- a/tests/test_version_service.py +++ b/tests/test_version_service.py @@ -1,12 +1,20 @@ """Tests for VersionService - version detection and update checks.""" +import subprocess from unittest.mock import MagicMock, patch +import pytest + from keboola_agent_cli.services.version_service import ( + MCP_BINARY_NAME, + MCP_PACKAGE_NAME, VersionService, + _detect_mcp_install_method, _fetch_mcp_latest_version, + _get_local_mcp_version, _is_up_to_date, _is_uvx_available, + _perform_mcp_update, ) @@ -72,6 +80,21 @@ def test_invalid_version(self) -> None: class TestVersionService: """Tests for VersionService.get_versions().""" + @pytest.fixture(autouse=True) + def _no_real_mcp_probe(self): + """Stub out the MCP detection helpers to avoid real subprocess calls.""" + with ( + patch( + "keboola_agent_cli.services.version_service._get_local_mcp_version", + return_value="1.46.0", + ), + patch( + "keboola_agent_cli.services.version_service._detect_mcp_install_method", + return_value="uv_tool", + ), + ): + yield + @patch("keboola_agent_cli.services.version_service._fetch_mcp_latest_version") @patch("keboola_agent_cli.services.version_service._is_uvx_available") def test_mcp_auto_updates( @@ -94,6 +117,11 @@ def test_mcp_auto_updates( assert mcp_dep["auto_updates"] is True assert mcp_dep["uvx_available"] is True assert mcp_dep["latest_version"] == "1.46.0" + # New fields (since v0.28.1) + assert mcp_dep["version"] == "1.46.0" + assert mcp_dep["up_to_date"] is True + assert mcp_dep["install_method"] == "uv_tool" + assert "uv tool upgrade" in mcp_dep["upgrade_command"] @patch("keboola_agent_cli.services.version_service._fetch_mcp_latest_version") @patch("keboola_agent_cli.services.version_service._is_uvx_available") @@ -126,3 +154,235 @@ def test_remote_check_fails( mcp_dep = result["dependencies"][0] assert mcp_dep["latest_version"] is None + assert mcp_dep["up_to_date"] is None # cannot compare without latest + + +# --------------------------------------------------------------------------- +# _get_local_mcp_version (since v0.28.1) +# --------------------------------------------------------------------------- + + +class TestGetLocalMcpVersion: + """Tests for the local-MCP-version detection helper.""" + + @patch("keboola_agent_cli.services.version_service.shutil.which") + @patch("keboola_agent_cli.services.version_service.subprocess.run") + def test_binary_returns_version(self, mock_run: MagicMock, mock_which: MagicMock) -> None: + mock_which.return_value = f"/usr/local/bin/{MCP_BINARY_NAME}" + mock_run.return_value = MagicMock( + returncode=0, stdout="keboola_mcp_server 1.59.1\n", stderr="" + ) + assert _get_local_mcp_version() == "1.59.1" + + @patch("keboola_agent_cli.services.version_service.shutil.which") + @patch("keboola_agent_cli.services.version_service.subprocess.run") + def test_binary_version_on_stderr(self, mock_run: MagicMock, mock_which: MagicMock) -> None: + """Some tools print --version output to stderr; we read both.""" + mock_which.return_value = f"/usr/local/bin/{MCP_BINARY_NAME}" + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="version 1.49.0\n") + assert _get_local_mcp_version() == "1.49.0" + + @patch("keboola_agent_cli.services.version_service.shutil.which", return_value=None) + def test_no_binary_no_metadata_returns_none(self, mock_which: MagicMock) -> None: + with patch( + "importlib.metadata.version", + side_effect=__import__("importlib.metadata").metadata.PackageNotFoundError( + MCP_PACKAGE_NAME + ), + ): + assert _get_local_mcp_version() is None + + @patch("keboola_agent_cli.services.version_service.shutil.which") + @patch( + "keboola_agent_cli.services.version_service.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="x", timeout=5), + ) + def test_binary_timeout_falls_through(self, mock_run: MagicMock, mock_which: MagicMock) -> None: + mock_which.return_value = f"/usr/local/bin/{MCP_BINARY_NAME}" + with patch( + "importlib.metadata.version", + side_effect=__import__("importlib.metadata").metadata.PackageNotFoundError( + MCP_PACKAGE_NAME + ), + ): + assert _get_local_mcp_version() is None + + +# --------------------------------------------------------------------------- +# _detect_mcp_install_method (since v0.28.1) +# --------------------------------------------------------------------------- + + +class TestDetectMcpInstallMethod: + """Tests for the MCP-install-method detector.""" + + @patch("keboola_agent_cli.services.version_service.shutil.which") + @patch("keboola_agent_cli.services.version_service.subprocess.run") + def test_uv_tool(self, mock_run: MagicMock, mock_which: MagicMock) -> None: + mock_which.side_effect = lambda c: { + MCP_BINARY_NAME: f"/home/user/.local/bin/{MCP_BINARY_NAME}", + "uv": "/usr/local/bin/uv", + }.get(c) + mock_run.return_value = MagicMock( + returncode=0, + stdout=f"{MCP_PACKAGE_NAME} v1.59.1\n", + stderr="", + ) + assert _detect_mcp_install_method() == "uv_tool" + + @patch("keboola_agent_cli.services.version_service.shutil.which") + @patch("keboola_agent_cli.services.version_service.subprocess.run") + def test_pip_env_when_binary_present_but_not_in_uv_tool( + self, mock_run: MagicMock, mock_which: MagicMock + ) -> None: + mock_which.side_effect = lambda c: { + MCP_BINARY_NAME: f"/usr/local/bin/{MCP_BINARY_NAME}", + "uv": "/usr/local/bin/uv", + }.get(c) + mock_run.return_value = MagicMock(returncode=0, stdout="other-tool v1.0.0\n", stderr="") + assert _detect_mcp_install_method() == "pip_env" + + @patch("keboola_agent_cli.services.version_service.shutil.which", return_value=None) + def test_uvx_fallback(self, mock_which: MagicMock) -> None: + # Two passes through which: fail for binary + uv, succeed for uvx. + mock_which.side_effect = lambda c: "/usr/local/bin/uvx" if c == "uvx" else None + with patch( + "importlib.metadata.distribution", + side_effect=__import__("importlib.metadata").metadata.PackageNotFoundError( + MCP_PACKAGE_NAME + ), + ): + assert _detect_mcp_install_method() == "uvx" + + @patch("keboola_agent_cli.services.version_service.shutil.which", return_value=None) + def test_none_when_nothing_available(self, mock_which: MagicMock) -> None: + with patch( + "importlib.metadata.distribution", + side_effect=__import__("importlib.metadata").metadata.PackageNotFoundError( + MCP_PACKAGE_NAME + ), + ): + assert _detect_mcp_install_method() == "none" + + +# --------------------------------------------------------------------------- +# _perform_mcp_update (since v0.28.1) +# --------------------------------------------------------------------------- + + +class TestPerformMcpUpdate: + """Tests for ``_perform_mcp_update``.""" + + @patch("keboola_agent_cli.services.version_service.shutil.which") + @patch("keboola_agent_cli.services.version_service.subprocess.run") + def test_uv_tool_success(self, mock_run: MagicMock, mock_which: MagicMock) -> None: + mock_which.return_value = "/usr/local/bin/uv" + mock_run.return_value = MagicMock(returncode=0, stdout="upgraded", stderr="") + ok, info = _perform_mcp_update(method="uv_tool") + assert ok is True + assert "upgraded" in info + # Verify the command shape we are about to run. + cmd = mock_run.call_args.args[0] + assert "tool" in cmd and "upgrade" in cmd and MCP_PACKAGE_NAME in cmd + + @patch("keboola_agent_cli.services.version_service.shutil.which") + @patch("keboola_agent_cli.services.version_service.subprocess.run") + def test_pip_env_success(self, mock_run: MagicMock, mock_which: MagicMock) -> None: + mock_which.return_value = "/usr/local/bin/pip" + mock_run.return_value = MagicMock(returncode=0, stdout="upgraded via pip", stderr="") + ok, _info = _perform_mcp_update(method="pip_env") + assert ok is True + cmd = mock_run.call_args.args[0] + assert "install" in cmd and "--upgrade" in cmd + + @patch("keboola_agent_cli.services.version_service.shutil.which") + @patch("keboola_agent_cli.services.version_service.subprocess.run") + def test_uvx_uses_refresh(self, mock_run: MagicMock, mock_which: MagicMock) -> None: + mock_which.return_value = "/usr/local/bin/uvx" + mock_run.return_value = MagicMock(returncode=0, stdout="cached refreshed", stderr="") + ok, _info = _perform_mcp_update(method="uvx") + assert ok is True + cmd = mock_run.call_args.args[0] + assert "--refresh" in cmd + + def test_none_returns_false(self) -> None: + ok, info = _perform_mcp_update(method="none") + assert ok is False + assert "not installed" in info + + @patch("keboola_agent_cli.services.version_service.shutil.which") + @patch( + "keboola_agent_cli.services.version_service.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="x", timeout=180), + ) + def test_timeout(self, mock_run: MagicMock, mock_which: MagicMock) -> None: + mock_which.return_value = "/usr/local/bin/uv" + ok, info = _perform_mcp_update(method="uv_tool", timeout=180.0) + assert ok is False + assert "timed out" in info + + +# --------------------------------------------------------------------------- +# VersionService.self_update -- two-stage upgrade (since v0.28.1) +# --------------------------------------------------------------------------- + + +class TestSelfUpdateTwoStage: + """Tests for the kbagent + MCP combined upgrade path.""" + + @patch("keboola_agent_cli.services.version_service._fetch_kbagent_latest_version") + @patch("keboola_agent_cli.services.version_service._fetch_mcp_latest_version") + @patch("keboola_agent_cli.services.version_service._get_local_mcp_version") + @patch("keboola_agent_cli.services.version_service._detect_mcp_install_method") + @patch("keboola_agent_cli.services.version_service._perform_mcp_update") + def test_both_up_to_date_no_subprocesses( + self, + mock_perform: MagicMock, + mock_detect: MagicMock, + mock_local: MagicMock, + mock_mcp_latest: MagicMock, + mock_kbagent_latest: MagicMock, + ) -> None: + from keboola_agent_cli import __version__ + + mock_kbagent_latest.return_value = __version__ + mock_mcp_latest.return_value = "1.59.1" + mock_local.return_value = "1.59.1" + mock_detect.return_value = "uv_tool" + + svc = VersionService() + result = svc.self_update() + + assert result["updated"] is False + assert result["kbagent"]["updated"] is False + assert result["mcp"]["updated"] is False + mock_perform.assert_not_called() + + @patch("keboola_agent_cli.services.version_service._fetch_kbagent_latest_version") + @patch("keboola_agent_cli.services.version_service._fetch_mcp_latest_version") + @patch("keboola_agent_cli.services.version_service._get_local_mcp_version") + @patch("keboola_agent_cli.services.version_service._detect_mcp_install_method") + @patch("keboola_agent_cli.services.version_service._perform_mcp_update") + def test_only_mcp_stale_kbagent_uptodate_still_runs_mcp( + self, + mock_perform: MagicMock, + mock_detect: MagicMock, + mock_local: MagicMock, + mock_mcp_latest: MagicMock, + mock_kbagent_latest: MagicMock, + ) -> None: + from keboola_agent_cli import __version__ + + mock_kbagent_latest.return_value = __version__ # kbagent up-to-date + mock_mcp_latest.return_value = "1.59.1" + mock_local.return_value = "1.49.0" # MCP stale + mock_detect.return_value = "uv_tool" + mock_perform.return_value = (True, "ok") + + svc = VersionService() + result = svc.self_update() + + # Critical: kbagent up-to-date does NOT short-circuit MCP stage. + assert result["mcp"]["updated"] is True + assert result["updated"] is True + mock_perform.assert_called_once() diff --git a/uv.lock b/uv.lock index ed8a97ac..347daeae 100644 --- a/uv.lock +++ b/uv.lock @@ -439,7 +439,7 @@ wheels = [ [[package]] name = "keboola-agent-cli" -version = "0.30.0" +version = "0.30.1" source = { editable = "." } dependencies = [ { name = "httpx" }, From 7e73384a7e5a9e8fb1d2743987aee1bfa88f5164 Mon Sep 17 00:00:00 2001 From: Petr Date: Thu, 7 May 2026 10:30:53 +0200 Subject: [PATCH 2/3] fix(0.30.1): address PR #257 review findings (B-1, B-2, NB-1..3, NIT-1..2) B-1: re-exec guard now skips ONLY the kbagent stage. Pre-fix `_should_skip()` gated the entire flow on KBAGENT_SKIP_UPDATE=1, so the re-exec'd process after a kbagent self-upgrade silently dropped the MCP stage -- exactly the case where both stages are most likely needed. Split into `_should_skip_kbagent_stage()` (re-exec guard only) and `_should_skip_all()` (dev install / opt-out / update|version commands). The orchestrator now consults each at the matching stage so Stage 2 always runs regardless of Stage 1's gate state. Backwards- compatible `_should_skip()` alias preserved for old callers. Regression test class `TestReExecPathStillRunsMcp` pins the contract: re-exec scenario calls Stage 2 exactly once; user opt-out and `kbagent update` still skip both. B-2: `_detect_mcp_install_method()` no longer uses a fragile `MCP_PACKAGE_NAME in stdout` substring match against `uv tool list` output. New `_uv_tool_list_has_mcp(stdout)` helper splits per-line, skips indented continuation lines (binary listings under a tool), and requires exact equality on the first whitespace-separated token of a non-indented line. Rejects similarly-named packages (`keboola-mcp-server-foo`), indented binary references (` - keboola_mcp_server` under another tool), and accidentally- merged stderr text. Seven new unit tests in `TestUvToolListHasMcp` cover exact match, similar-name false-positive rejection, indented-only input, multi-tool listings, empty / blank input, and trailing whitespace tolerance. NB-1: `commands/version.py::_format_dep_auto_update` now reports the locally installed version + up-to-date status. Previously human-mode `kbagent version` ignored the new `version` and `up_to_date` fields and showed only the PyPI latest, defeating the staleness- visibility goal in non-JSON mode. Renderer now mirrors `_format_dep_standard` for the auto-update branch (local + ->latest when stale + 'auto-updates (up to date)' when fresh + 'not installed' fallback for install_method=='none'). NB-2: `commands/context.py` AGENT_CONTEXT entries for `kbagent update` and `kbagent version` rewritten to describe the two-stage upgrade (kbagent + keboola-mcp-server) and the always-runs-on-startup semantics. Pre-fix the AGENT_CONTEXT still claimed `update` was kbagent-only, leaving AI agents unable to explain the new behaviour to users when asked. NB-3: timeouts `5` and `180.0` lifted into `MCP_PROBE_TIMEOUT` and `MCP_UPGRADE_TIMEOUT` constants in `constants.py` per CONTRIBUTING.md 'no magic numbers' rule. NIT-1: 7 stale `(since v0.28.1)` references in `tests/test_auto_update.py` and `tests/test_version_service.py` updated to `(since v0.30.1)`. NIT-2: `update_command` docstring rewritten to describe the two-stage flow (no longer says 'self-update kbagent' as if MCP weren't part of it). Companion docstring on `_format_dep_auto_update` no longer references uvx @latest as the auto-update mechanism. 89 unit tests pass (added: 3 B-1 regression + 7 B-2 parser + minor fixture adjustments). `make check` clean. --- plugins/kbagent/skills/kbagent/SKILL.md | 2 +- src/keboola_agent_cli/auto_update.py | 141 +++++++++++------ src/keboola_agent_cli/commands/context.py | 9 +- src/keboola_agent_cli/commands/version.py | 57 +++++-- src/keboola_agent_cli/constants.py | 11 ++ .../services/version_service.py | 49 +++++- tests/test_auto_update.py | 143 +++++++++++++++++- tests/test_version_service.py | 67 +++++++- 8 files changed, 402 insertions(+), 77 deletions(-) diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index 651f7e36..7000b366 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -83,7 +83,7 @@ When working inside a git repository or project directory, run `kbagent init` (o | Goal | Command | |------|---------| -| Update kbagent to the latest version | `kbagent update` | +| Update kbagent + keboola-mcp-server to the latest versions | `kbagent update` | | Show recent changelog (what changed in each version) | `kbagent changelog` | | Search for items (tables, buckets, configs, flows, …) by name or content | `kbagent search ` | | List all operations with their risk category and current allowed/denied status | `kbagent permissions list` | diff --git a/src/keboola_agent_cli/auto_update.py b/src/keboola_agent_cli/auto_update.py index 84a0b0c2..569be80e 100644 --- a/src/keboola_agent_cli/auto_update.py +++ b/src/keboola_agent_cli/auto_update.py @@ -27,6 +27,7 @@ ENV_AUTO_UPDATE, ENV_SKIP_UPDATE, KBAGENT_INSTALL_SOURCE, + MCP_UPGRADE_TIMEOUT, VERSION_CACHE_FILENAME, VERSION_CHECK_TIMEOUT, ) @@ -74,17 +75,24 @@ def _read_cache() -> dict | None: def _write_cache( - latest_version: str, + latest_version: str | None, mcp_latest_version: str | None = None, mcp_install_method: str | None = None, ) -> None: """Write the version cache file. Args: - latest_version: kbagent latest version (required). + latest_version: kbagent latest version. Falls back to the running + interpreter's ``__version__`` when None -- caller is in a + re-exec'd process where Stage 1 was skipped and we still want + to persist the MCP-side fields without losing the kbagent key. mcp_latest_version: keboola-mcp-server latest version from PyPI. mcp_install_method: Detected MCP install method (drives upgrade cmd). """ + if latest_version is None: + # Re-exec path: persist the running version so cache_is_fresh + # logic on the NEXT run still has a kbagent-side anchor. + latest_version = __version__ cache_path = _get_cache_path() try: cache_path.parent.mkdir(parents=True, exist_ok=True) @@ -141,19 +149,32 @@ def _is_dev_install() -> bool: return False -def _should_skip() -> bool: - """Determine whether the auto-update check should be skipped. +def _should_skip_kbagent_stage() -> bool: + """Whether the kbagent self-upgrade stage should be skipped. - Skip conditions: - - KBAGENT_SKIP_UPDATE=1 (set by re-exec to prevent loops) - - KBAGENT_AUTO_UPDATE in {false, 0, no} (user opt-out) - - Development/editable install - - Current command is 'update' or 'version' (handled separately) + Re-exec guard (``KBAGENT_SKIP_UPDATE=1``) skips ONLY the kbagent stage. + The MCP stage in the re-exec'd process is intentionally allowed to + proceed -- otherwise a freshly-upgraded kbagent on a stale MCP would + require a second invocation to refresh MCP. See + :func:`_should_skip_all` for the wider conditions that gate both stages. """ - # Re-exec guard - if os.environ.get(ENV_SKIP_UPDATE) == "1": - return True + return os.environ.get(ENV_SKIP_UPDATE) == "1" + + +def _should_skip_all() -> bool: + """Whether the entire auto-update flow should be skipped. + Skip conditions (apply to BOTH kbagent and MCP stages): + + - ``KBAGENT_AUTO_UPDATE`` in ``{false, 0, no}`` (user opt-out). + - Development / editable install (we never auto-upgrade a dev tree). + - Current command is ``update`` / ``version`` (those commands handle + versioning themselves and would loop if auto-update fired here too). + + Notably **does NOT include** the re-exec guard + ``KBAGENT_SKIP_UPDATE=1`` -- that is per-stage and only skips the + kbagent stage. See :func:`_should_skip_kbagent_stage`. + """ # User opt-out auto_update_val = os.environ.get(ENV_AUTO_UPDATE, "").lower().strip() if auto_update_val in ("false", "0", "no"): @@ -173,6 +194,17 @@ def _should_skip() -> bool: return False +def _should_skip() -> bool: + """Backwards-compatible alias for the old gate-everything check. + + Pre-v0.30.1 callers (and our own tests) treated this as a single skip + decision for the whole flow. Today it is the OR of the kbagent-stage + re-exec guard and the wider dev/opt-out gate -- the call sites in + :func:`maybe_auto_update` now consult the two helpers separately. + """ + return _should_skip_kbagent_stage() or _should_skip_all() + + def _perform_update(latest_version: str) -> bool: """Download and install the latest version. @@ -287,7 +319,7 @@ def _maybe_update_mcp(cache: dict | None, fetched_now: bool) -> str | None: f"Updating keboola-mcp-server v{local_version or 'unknown'} -> v{mcp_latest}" f" (via {method})...\n" ) - success, info = _perform_mcp_update(method=method, timeout=180.0) + success, info = _perform_mcp_update(method=method, timeout=MCP_UPGRADE_TIMEOUT) if success: post_version = _get_local_mcp_version() or mcp_latest sys.stderr.write(f"Updated keboola-mcp-server to v{post_version}.\n") @@ -303,7 +335,7 @@ def maybe_auto_update() -> None: """Main entry point for the auto-update flow. Called from ``cli.py`` at the very top of ``main()``. Orchestrates two - sequential stages: + sequential stages with **independent skip gating** (since v0.30.1): 1. **kbagent self-update** -- if the installed version is behind the latest GitHub release, download the upgrade and ``execvpe`` the new @@ -315,6 +347,13 @@ def maybe_auto_update() -> None: No re-exec is needed: the MCP server is spawned by ``tool call`` commands and the next spawn picks up the new version. + Critical invariant: **the re-exec'd process (KBAGENT_SKIP_UPDATE=1) + skips ONLY Stage 1**. Stage 2 always runs, so a kbagent self-upgrade + on startup leaves the user with both kbagent AND MCP refreshed in + a single boot, not two. This was the B-1 finding in the PR #257 + review -- gating the MCP stage on the same flag broke the + "both stages always run" promise after a kbagent self-upgrade. + Cache discipline: a single cache file at ``~/.config/keboola-agent-cli/version_cache.json`` stores both ``latest_version`` (kbagent) and ``mcp_latest_version`` so we make at @@ -324,44 +363,60 @@ def maybe_auto_update() -> None: debug level so the CLI always proceeds normally. """ try: - if _should_skip(): + # Wide gates (dev install / opt-out / update|version commands) + # skip BOTH stages -- there is nothing reasonable to do. + if _should_skip_all(): return cache = _read_cache() cache_is_fresh = bool(cache and _is_cache_fresh(cache, AUTO_UPDATE_CHECK_INTERVAL)) - - # Stage 1: kbagent self-update. - if cache_is_fresh: - latest_version: str | None = cache.get("latest_version") # type: ignore[union-attr] - else: - latest_version = _fetch_kbagent_latest_version(timeout=VERSION_CHECK_TIMEOUT) - - if latest_version is not None: - up_to_date = _is_up_to_date(__version__, latest_version) - if up_to_date is False: - sys.stderr.write(f"Updating kbagent v{__version__} -> v{latest_version}...\n") - if _perform_update(latest_version): - sys.stderr.write(f"Updated to v{latest_version}. Re-launching...\n") - # Persist cache before re-exec so the new process does - # not refetch immediately. - _write_cache( - latest_version, - mcp_latest_version=cache.get("mcp_latest_version") if cache else None, - mcp_install_method=cache.get("mcp_install_method") if cache else None, - ) - os.environ[ENV_UPDATED_FROM] = __version__ - _re_exec() - return # Defensive: _re_exec replaces the process. - sys.stderr.write("Auto-update failed; continuing with current version.\n") - - # Stage 2: keboola-mcp-server update. + latest_version: str | None = None + + # ----- Stage 1: kbagent self-update -------------------------------- + # The re-exec guard skips ONLY this stage (so a freshly upgraded + # kbagent in the re-exec'd process does NOT double-upgrade itself + # but still proceeds to Stage 2 below). + if not _should_skip_kbagent_stage(): + if cache_is_fresh: + latest_version = cache.get("latest_version") # type: ignore[union-attr] + else: + latest_version = _fetch_kbagent_latest_version(timeout=VERSION_CHECK_TIMEOUT) + + if latest_version is not None: + up_to_date = _is_up_to_date(__version__, latest_version) + if up_to_date is False: + sys.stderr.write(f"Updating kbagent v{__version__} -> v{latest_version}...\n") + if _perform_update(latest_version): + sys.stderr.write(f"Updated to v{latest_version}. Re-launching...\n") + # Persist cache before re-exec so the new process does + # not refetch immediately. The re-exec'd process will + # skip Stage 1 (KBAGENT_SKIP_UPDATE=1) and run Stage 2 + # against the just-refreshed cache. + _write_cache( + latest_version, + mcp_latest_version=cache.get("mcp_latest_version") if cache else None, + mcp_install_method=cache.get("mcp_install_method") if cache else None, + ) + os.environ[ENV_UPDATED_FROM] = __version__ + _re_exec() + return # Defensive: _re_exec replaces the process. + sys.stderr.write("Auto-update failed; continuing with current version.\n") + + # ----- Stage 2: keboola-mcp-server update -------------------------- + # Always runs (subject only to _should_skip_all above). After a + # kbagent self-upgrade, this is the re-exec'd process executing + # Stage 2 for the first time -- exactly the path B-1 broke before. mcp_latest = _maybe_update_mcp(cache, fetched_now=not cache_is_fresh) mcp_install_method = _detect_mcp_install_method() # Persist combined cache (kbagent + MCP) when we did any fresh fetch. - if not cache_is_fresh and latest_version is not None: + # Note: in the re-exec'd path, latest_version stays None (Stage 1 + # was skipped); _write_cache handles that by falling back to the + # running __version__, so we still persist the MCP-side fields and + # don't break the next run's cache TTL check. + if not cache_is_fresh: _write_cache( - latest_version, + latest_version=latest_version or (cache.get("latest_version") if cache else None), mcp_latest_version=mcp_latest, mcp_install_method=mcp_install_method, ) diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 9ac8b23b..a5750ce1 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -780,10 +780,15 @@ Health checks. --fix auto-installs MCP server binary. kbagent version - Version info, update check for kbagent and MCP server. + Version info for kbagent + keboola-mcp-server. Reports both the locally + installed version and the latest available; flags any staleness. kbagent update - Self-update kbagent to latest version (via uv tool install --upgrade). + Two-stage upgrade (since 0.30.1): kbagent itself AND keboola-mcp-server. + The MCP server is detected (uv tool / pip env / uvx) and bumped via the + matching command. Both stages always run, regardless of whether kbagent + itself needed an upgrade. The same flow runs automatically on every + kbagent startup -- the explicit `update` command forces a fresh check. kbagent changelog [--limit N] Show recent changelog (what changed in each version). Default: last 5 versions. diff --git a/src/keboola_agent_cli/commands/version.py b/src/keboola_agent_cli/commands/version.py index fbbfdbf8..1104082f 100644 --- a/src/keboola_agent_cli/commands/version.py +++ b/src/keboola_agent_cli/commands/version.py @@ -41,24 +41,45 @@ def _format_dep_standard(text: Text, dep: dict) -> None: def _format_dep_auto_update(text: Text, dep: dict) -> None: - """Format an auto-updating dependency (runs via uvx @latest).""" + """Format an auto-updating dependency. + + Since v0.30.1: dependencies are auto-updated by ``auto_update.py`` on + kbagent startup (and explicitly by ``kbagent update``). The renderer + surfaces the locally installed version + up-to-date status so a user + in the terminal sees the staleness signal immediately, not just in + JSON mode. + """ name = dep["name"] desc = dep["description"] + local = dep.get("version") latest = dep.get("latest_version") - uvx_available = dep.get("uvx_available", False) + up_to_date = dep.get("up_to_date") + install_method = dep.get("install_method", "?") + upgrade_cmd = dep.get("upgrade_command", "") label = f"{name} ({desc})" text.append(f" {label:<28}") - if not uvx_available: - text.append("uvx not found", style="red") - text.append(" (install: brew install uv)", style="dim") - elif latest: - text.append(f"v{latest}", style="green") - text.append(" auto-updates", style="dim") + if local is None: + if install_method == "none": + text.append("not installed", style="yellow") + if upgrade_cmd: + text.append(f" ({upgrade_cmd})", style="dim") + else: + text.append("local version unknown", style="dim") + if latest: + text.append(f" (latest on PyPI: v{latest})", style="dim") + text.append("\n") + return + + text.append(f"v{local}") + + if up_to_date is False and latest is not None: + text.append(f" -> v{latest} (auto-updates on next startup)", style="yellow") + elif up_to_date is True: + text.append(" auto-updates (up to date)", style="green") else: - text.append("available", style="green") - text.append(" (version check failed)", style="dim") + text.append(" (update check failed)", style="dim") text.append("\n") @@ -95,10 +116,20 @@ def version_command(ctx: typer.Context) -> None: def update_command(ctx: typer.Context) -> None: - """Update kbagent to the latest version. + """Update kbagent + keboola-mcp-server to the latest versions. + + Two-stage upgrade (since v0.30.1): + + 1. **kbagent** -- ``uv tool install --upgrade`` (preferred) or + ``pip install --upgrade`` from the GitHub repository. + 2. **keboola-mcp-server** -- detects install method and runs the + matching upgrade command (``uv tool upgrade`` / ``pip install -U`` + / ``uvx --refresh``). Always runs, regardless of whether kbagent + itself needed an upgrade. - Uses 'uv tool install --upgrade' (preferred) or 'pip install --upgrade' - to install the latest version from the GitHub repository. + JSON output reports both stages independently. Human mode prints a + one-line summary such as + ``kbagent v0.30.0 -> v0.30.1 | keboola-mcp-server v1.49.0 -> v1.59.1``. """ formatter = get_formatter(ctx) version_service = get_service(ctx, "version_service") diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index 7851a3f5..e9ce3cf2 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -154,6 +154,17 @@ KBAGENT_GITHUB_REPO: str = "padak/keboola_agent_cli" KBAGENT_INSTALL_SOURCE: str = "git+https://github.com/padak/keboola_agent_cli" +# --- MCP self-upgrade (since v0.30.1) --- +# Subprocess timeout for the `keboola_mcp_server --version` probe and the +# `uv tool list` install-method probe. These are local subprocess calls, +# so 5s leaves plenty of headroom for cold-start CPython without slowing +# kbagent startup observably. +MCP_PROBE_TIMEOUT: float = 5.0 +# Subprocess timeout for the actual upgrade command (`uv tool upgrade` / +# `pip install -U` / `uvx --refresh`). Network bound; 180s tolerates a +# slow PyPI link plus the worst-case dependency-resolution cost. +MCP_UPGRADE_TIMEOUT: float = 180.0 + # --- Auto-Update --- ENV_AUTO_UPDATE: str = "KBAGENT_AUTO_UPDATE" ENV_SKIP_UPDATE: str = "KBAGENT_SKIP_UPDATE" diff --git a/src/keboola_agent_cli/services/version_service.py b/src/keboola_agent_cli/services/version_service.py index 7d5afb1c..cd484942 100644 --- a/src/keboola_agent_cli/services/version_service.py +++ b/src/keboola_agent_cli/services/version_service.py @@ -20,7 +20,9 @@ from ..constants import ( KBAGENT_GITHUB_REPO, KBAGENT_INSTALL_SOURCE, + MCP_PROBE_TIMEOUT, MCP_PYPI_URL, + MCP_UPGRADE_TIMEOUT, VERSION_CHECK_TIMEOUT, ) @@ -36,7 +38,7 @@ def _is_uvx_available() -> bool: return shutil.which("uvx") is not None -def _get_local_mcp_version(timeout: float = 5.0) -> str | None: +def _get_local_mcp_version(timeout: float = MCP_PROBE_TIMEOUT) -> str | None: """Detect the locally installed keboola-mcp-server version. Resolution order: @@ -86,6 +88,43 @@ def _get_local_mcp_version(timeout: float = 5.0) -> str | None: return None +def _uv_tool_list_has_mcp(stdout: str) -> bool: + """Detect whether ``uv tool list`` output contains ``keboola-mcp-server``. + + Robust against three classes of false-positive that a naive substring + match (``MCP_PACKAGE_NAME in stdout``) would suffer: + + * **Similarly-named packages** -- e.g. a hypothetical + ``keboola-mcp-server-foo`` would substring-match but is NOT the same + tool; we want exact equality on the first whitespace-separated token. + * **Indented binary listings** -- ``uv tool list`` shows each tool's + published scripts on indented continuation lines (`` -