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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/kbagent/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/kbagent/skills/kbagent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ When working inside a git repository or project directory, run `kbagent init` (o
<!-- BEGIN AUTO-GENERATED COMMANDS -->
| 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 <QUERY>` |
| List all operations with their risk category and current allowed/denied status | `kbagent permissions list` |
Expand Down
27 changes: 27 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
258 changes: 203 additions & 55 deletions src/keboola_agent_cli/auto_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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"):
Expand All @@ -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.

Expand Down Expand Up @@ -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)
Loading
Loading