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.2",
"version": "0.30.3",
"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.2",
"version": "0.30.3",
"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 pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keboola-agent-cli"
version = "0.30.2"
version = "0.30.3"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
65 changes: 63 additions & 2 deletions src/keboola_agent_cli/auto_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@
logger = logging.getLogger(__name__)


# Process-level sentinel for the auto-update flow.
#
# Bug D fix from issue #263: ``kbagent repl`` re-enters the entire CLI
# (and therefore ``main()`` -> ``maybe_auto_update()``) on every prompt
# iteration. Pre-fix, the auto-update banner re-fired once per command
# typed at the prompt -- one fetch, one (potentially failing) upgrade
# attempt, one stderr write. The sentinel short-circuits subsequent
# in-process invocations after the first.
#
# Re-exec'd processes (kbagent self-upgrade -> ``execvpe`` to new binary)
# start with a fresh sentinel because the module is reloaded into a new
# Python interpreter, so the kbagent-self-upgrade -> re-exec -> MCP-stage
# chain from PR #257 is preserved.
_AUTO_UPDATE_RAN: bool = False


def _get_cache_path() -> Path:
"""Return path to the version cache file.

Expand Down Expand Up @@ -306,6 +322,17 @@ def _maybe_update_mcp(cache: dict | None, fetched_now: bool) -> str | None:
return cached_latest # nothing to do; preserve any prior cache

local_version = _get_local_mcp_version()
if local_version is None:
# Bug C fix from issue #263: when local-version detection fails,
# do NOT fall through to the upgrade attempt. The previous behaviour
# printed an "Updating ... vunknown -> v1.59.1" banner and ran the
# upgrade subprocess every TTL window because `up_to_date` was None
# (not True), which bypassed the short-circuit below. The fix opts
# out of the upgrade for this TTL window and lets the next fresh-
# cache pass retry detection. The cache write below records the
# latest version regardless so the cache TTL still ticks.
return mcp_latest

up_to_date = _is_up_to_date(local_version, mcp_latest)
if up_to_date is True:
return mcp_latest
Expand All @@ -319,10 +346,34 @@ 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"
)
pre_version = local_version
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")
# Bug E fix from issue #263: subprocess returncode == 0 is NOT
# enough to claim the upgrade actually happened. `uv tool upgrade`
# exits 0 even when its resolver backtracks to the previously
# installed version (a real-world reproducer: keboola-mcp-server
# v1.59.1 declares fastmcp==3.2.0 strict equality, the existing
# venv has fastmcp==2.13.0.2, uv resolves back to v1.32.0 and
# exits clean). Compare pre and post versions and tell the truth.
post_version = _get_local_mcp_version()
if post_version and pre_version and post_version != pre_version:
sys.stderr.write(f"Updated keboola-mcp-server to v{post_version}.\n")
elif post_version is None:
# Probe failed post-upgrade; cannot verify -- assume latest.
sys.stderr.write(
f"Updated keboola-mcp-server (probe failed; latest on PyPI: v{mcp_latest}).\n"
)
else:
# Subprocess exit 0 but local version unchanged. Most common
# cause: dependency-resolver backtrack (Python or transitive-
# dep constraint cannot satisfy the latest). Surface a
# diagnostic instead of silently lying.
sys.stderr.write(
f"keboola-mcp-server upgrade exit 0 but local version still v{pre_version} "
f"(latest: v{mcp_latest}). Possible Python or dependency-version mismatch -- "
f"run `uv tool install --reinstall keboola-mcp-server` to diagnose.\n"
)
else:
sys.stderr.write(
f"keboola-mcp-server upgrade skipped: {info}; continuing with current version.\n"
Expand Down Expand Up @@ -362,7 +413,17 @@ def maybe_auto_update() -> None:
This function NEVER raises. All exceptions are caught and logged at
debug level so the CLI always proceeds normally.
"""
global _AUTO_UPDATE_RAN
try:
# Bug D fix from issue #263: per-process sentinel. ``kbagent repl``
# re-enters main() on every prompt; without this gate the auto-
# update flow fired (and printed banners) once per command typed.
# Set BEFORE any work so a crash mid-flow still gates subsequent
# in-process re-entries.
if _AUTO_UPDATE_RAN:
return
_AUTO_UPDATE_RAN = True

# Wide gates (dev install / opt-out / update|version commands)
# skip BOTH stages -- there is nothing reasonable to do.
if _should_skip_all():
Expand Down
7 changes: 7 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@

# Ordered newest-first. Each value is a list of brief one-line descriptions.
CHANGELOG: dict[str, list[str]] = {
"0.30.3": [
"Fix: `_perform_mcp_update` for `uvx`-cache installs now promotes to `uv tool install --upgrade keboola-mcp-server` instead of running the broken `uvx --refresh --from <pkg> <bin> --version` chain. The trailing `--version` arg was rejected by the upstream MCP binary (no such flag), so the upgrade subprocess always exited non-zero and the user-facing banner reported failure even when the cache refresh itself worked. Promoting to `uv tool install --upgrade` does the equivalent refresh AND moves the binary to PATH so subsequent runs use the faster `uv_tool` detection path. Bug B fix from issue #263.",
"Fix: `_maybe_update_mcp` now skips the upgrade attempt when the local-version probe returns `None`. Pre-fix, probe-`None` left `up_to_date == None` (not `True`), the short-circuit was bypassed, and the function fell through to a broken upgrade subprocess every TTL window. The user saw an `Updating ... vunknown -> v1.59.1` banner once per kbagent invocation. Post-fix, probe-`None` opts out of the upgrade for this TTL window; the next fresh-cache pass will retry detection. Cache TTL still ticks. Bug C fix from issue #263.",
"Fix: `maybe_auto_update` now uses a process-level sentinel (`_AUTO_UPDATE_RAN`) to short-circuit subsequent in-process invocations. `kbagent repl` re-enters `main()` -> `maybe_auto_update()` on every prompt iteration; pre-fix, the auto-update banner re-fired once per command typed at the prompt. The sentinel flips to True BEFORE any work so a crash mid-flow still gates subsequent re-entries. Re-exec'd processes (kbagent self-upgrade -> `execvpe` to new binary) start with a fresh sentinel because the module is reloaded into a new interpreter, so the kbagent-self-upgrade -> re-exec -> MCP-stage chain from PR #257 is preserved. Bug D fix from issue #263.",
"Fix: kbagent no longer reports a successful MCP upgrade when the subprocess returncode == 0 but the local version did not change. `uv tool upgrade keboola-mcp-server` exits 0 even when its dependency resolver backtracks to the previously installed version (real-world reproducer from issue #263: `keboola-mcp-server v1.59.1` declares a `fastmcp==3.2.0` strict-equality constraint that conflicts with the installed `fastmcp==2.13.0.2`, so uv silently resolves to v1.32.0 and exits clean). Pre-fix, kbagent printed `Updated keboola-mcp-server to v1.32.0.` -- the same version the user started from -- once per kbagent invocation. Post-fix, both `_maybe_update_mcp` (auto-update path) and `VersionService._update_mcp` (`kbagent update` path) compare pre-upgrade and post-upgrade versions; only declare success when they actually differ; otherwise emit a diagnostic pointing to `uv tool install --reinstall keboola-mcp-server` and surface the underlying constraint as the likely cause. Bug E fix from issue #263 (reported by @ottomansky on v0.30.2).",
"Tests: 5 new regression tests pinning the four contracts. `TestPerformMcpUpdate.test_uvx_promotes_to_uv_tool_install` asserts the new uvx command shape and explicitly checks that `--version` is GONE from the cmd. `TestPerformMcpUpdate.test_uvx_promotion_requires_uv` covers the missing-`uv` failure path. `TestProbeNoneSkipsUpgrade.test_local_version_none_skips_upgrade` mocks the probe to return None and asserts `_perform_mcp_update` is never called. `TestProcessLevelSentinel.test_second_call_short_circuits` calls `maybe_auto_update()` three times in the same process and asserts the MCP stage runs exactly once. `TestProcessLevelSentinel.test_sentinel_is_set_even_when_body_raises` verifies the flag flips before any work so a flaky upstream PyPI fetch cannot re-fire the banner per prompt. `TestSelfUpdateTwoStage.test_subprocess_succeeds_but_version_unchanged_reports_not_updated` simulates the @ottomansky reproducer (pre and post both `1.32.0`) and asserts `result['mcp']['updated'] is False` plus a diagnostic message containing `uv tool install --reinstall`. Existing `TestMaybeAutoUpdate`, `TestMaybeAutoUpdateMcpIntegration`, and `TestReExecPathStillRunsMcp` autouse fixtures extended to reset `_AUTO_UPDATE_RAN` between tests so the sentinel does not gate the second test in each class.",
],
"0.30.2": [
"Fix: `kbagent version` now correctly reports the locally installed `keboola-mcp-server` version. v0.30.1's detection probed `keboola_mcp_server --version`, but the upstream MCP binary does NOT honour `--version` -- it prints its argparse usage block with returncode 0, so the regex found no match and the command displayed `local version unknown` despite a perfectly working install. Reported by an actual user on a fresh upgrade: `kbagent update` printed `keboola-mcp-server vunknown -> v1.59.1` and the version panel said `local version unknown`. The fix moves `uv tool list` to the **preferred** detection path (canonical for the kbagent doctor --fix install method, exact `keboola-mcp-server v1.59.1` line), with `importlib.metadata` and the existing `keboola_mcp_server --version` probe retained as fallbacks. The binary-probe fallback now also strips `usage:` lines before regex-matching so a future `python3.12.9` path component cannot be mistaken for a version. New helper `_uv_tool_list_get_mcp_version(stdout)` parses the `uv tool list` output line-by-line, requires exact first-token equality on the package name, validates the second token as semver-ish, and strips the leading `v`. 8 new unit tests in `TestUvToolListGetMcpVersion` plus 5 rewritten `TestGetLocalMcpVersion` tests including a real-world regression test pinning the upstream usage-help output verbatim.",
],
Expand Down
78 changes: 56 additions & 22 deletions src/keboola_agent_cli/services/version_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,18 +293,18 @@ def _perform_mcp_update(
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",
]
# Promote uvx-cache install to a persistent `uv tool install`.
# The previous strategy (`uvx --refresh ... <bin> --version`) was
# broken: the upstream MCP binary does NOT honour --version (it
# rejects the arg and exits non-zero), so the upgrade banner
# always reported failure even when the cache refresh itself
# worked. `uv tool install --upgrade` does the equivalent
# refresh AND moves the binary to PATH so subsequent runs use the
# faster `uv_tool` detection path. Bug B fix from issue #263.
uv_path = shutil.which("uv")
if uv_path is None:
return False, "uv not found on PATH (needed to promote uvx cache to uv tool)"
cmd = [uv_path, "tool", "install", "--upgrade", MCP_PACKAGE_NAME]
elif method == "none":
return False, "keboola-mcp-server is not installed"
else:
Expand Down Expand Up @@ -430,11 +430,16 @@ def get_versions(self) -> dict[str, Any]:
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.
# Map install method to the upgrade command shown to users. Must
# match what `_perform_mcp_update` actually runs internally -- since
# v0.30.3 the uvx path promotes to `uv tool install --upgrade`
# (Bug B fix from issue #263), so the user-facing recommendation
# must reflect that, not the broken `uvx --refresh ... --version`
# chain the v0.30.1 logic used.
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",
"uvx": f"uv tool install --upgrade {MCP_PACKAGE_NAME}",
"none": f"uv tool install {MCP_PACKAGE_NAME}",
}

Expand Down Expand Up @@ -633,18 +638,47 @@ def _update_mcp() -> dict[str, Any]:
success, output = _perform_mcp_update(method=method, timeout=MCP_UPGRADE_TIMEOUT)
post_version = _get_local_mcp_version() if success else local_version

# Bug E fix from issue #263: subprocess returncode == 0 is NOT
# enough to claim the upgrade happened. `uv tool upgrade` exits 0
# even when its resolver backtracks to the previously installed
# version (e.g. a transitive-dep constraint blocks the latest).
# `updated` reflects the actual version delta, not just exit code.
# The four success-branch cases:
# 1. pre is None, post is set -> fresh install; updated=True
# 2. pre is set, post is set, != -> normal upgrade; updated=True
# 3. pre is set, post is set, == -> Bug E no-op; updated=False
# 4. pre / post unknown -> probe failure; updated=False
actually_updated = bool(
success and post_version and (local_version is None or post_version != local_version)
)

if not success:
message = f"keboola-mcp-server upgrade failed: {output}"
elif actually_updated:
message = (
f"Upgraded keboola-mcp-server "
f"({local_version or 'unknown'} -> {post_version}) via {method}."
)
elif post_version is None:
message = (
f"keboola-mcp-server upgrade ran via {method}; post-upgrade probe failed "
f"(latest on PyPI: v{latest_version})."
)
else:
# Subprocess exit 0 but local version unchanged.
message = (
f"keboola-mcp-server upgrade exit 0 but local version still "
f"v{local_version} (latest: v{latest_version}). Possible Python or "
f"dependency-version mismatch -- run `uv tool install --reinstall "
f"{MCP_PACKAGE_NAME}` to diagnose."
)

return {
"updated": bool(success),
"updated": actually_updated,
"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}"
),
"message": message,
"output": output,
}
Loading
Loading