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/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/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..569be80e 100644 --- a/src/keboola_agent_cli/auto_update.py +++ b/src/keboola_agent_cli/auto_update.py @@ -27,10 +27,18 @@ ENV_AUTO_UPDATE, ENV_SKIP_UPDATE, KBAGENT_INSTALL_SOURCE, + MCP_UPGRADE_TIMEOUT, 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 +56,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 +74,36 @@ def _read_cache() -> dict | None: return None -def _write_cache(latest_version: str) -> None: +def _write_cache( + 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: The latest version string to cache. + 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) - 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 @@ -121,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"): @@ -153,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. @@ -224,54 +276,150 @@ def show_post_update_changelog() -> None: pass # Never crash -def maybe_auto_update() -> None: - """Main entry point for the auto-update flow. +def _maybe_update_mcp(cache: dict | None, fetched_now: bool) -> str | None: + """Check for and apply a keboola-mcp-server upgrade. - 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 + 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. - This function NEVER raises. Any exception is caught and silently - logged so the CLI always proceeds normally. + Returns: + ``mcp_latest_version`` to persist to the cache (None if skipped or + fetch failed). Caller composes the cache write. """ - try: - if _should_skip(): - return - - cache = _read_cache() - latest_version: str | None = None + # 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=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") + else: + sys.stderr.write( + f"keboola-mcp-server upgrade skipped: {info}; continuing with current version.\n" + ) - if cache and _is_cache_fresh(cache, AUTO_UPDATE_CHECK_INTERVAL): - latest_version = cache.get("latest_version") - else: - latest_version = _fetch_kbagent_latest_version(timeout=VERSION_CHECK_TIMEOUT) - if latest_version: - _write_cache(latest_version) + return mcp_latest - 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") +def maybe_auto_update() -> None: + """Main entry point for the auto-update flow. - if not _perform_update(latest_version): - sys.stderr.write("Auto-update failed; continuing with current version.\n") + Called from ``cli.py`` at the very top of ``main()``. Orchestrates two + 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 + 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. + + 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 + 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: + # Wide gates (dev install / opt-out / update|version commands) + # skip BOTH stages -- there is nothing reasonable to do. + if _should_skip_all(): 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() + cache = _read_cache() + cache_is_fresh = bool(cache and _is_cache_fresh(cache, AUTO_UPDATE_CHECK_INTERVAL)) + latest_version: str | None = None - # If re-exec fails (shouldn't happen), continue with old version + # ----- 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. + # 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 or (cache.get("latest_version") if cache else None), + 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/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/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..cd484942 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 @@ -17,18 +20,228 @@ from ..constants import ( KBAGENT_GITHUB_REPO, KBAGENT_INSTALL_SOURCE, + MCP_PROBE_TIMEOUT, MCP_PYPI_URL, + MCP_UPGRADE_TIMEOUT, VERSION_CHECK_TIMEOUT, ) 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 = MCP_PROBE_TIMEOUT) -> 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 _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 (`` -