diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 288e0ee4..aad85f5c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.80.0", + "version": "0.81.0", "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/CLAUDE.md b/CLAUDE.md index ada602fa..5059a4a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -657,6 +657,9 @@ kbagent context kbagent init [--from-global] [--project ALIAS ...] # `--project ALIAS` (repeatable) copies only the named project(s) from the global config and implies --from-global. kbagent doctor [--fix] +# `doctor` includes an `mcp_tool_tasks` check (0.81.0+): warns about scheduled agent tasks +# still using `--type mcp_tool` (removed in v0.85.0); they run unattended and get no warning +# at removal. `agent list` marks those rows and adds a per-task `deprecation` key in --json. kbagent version [--beta] kbagent update [--beta] # `--beta` (or env `KBAGENT_INCLUDE_PRERELEASE=1`) opts into pre-release versions diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index 7f9a7a9f..dcfc0ac2 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.80.0", + "version": "0.81.0", "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/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index e1075d74..d61c43f4 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -367,7 +367,7 @@ CLI parity for the `/agents` REST surface. Reads/writes `/agents.jso ## Utility - `init [--from-global] [--project ALIAS ...]` -- create local `.kbagent/` workspace (per-directory isolation); `--project ALIAS` (repeatable) copies only the named project(s) and implies `--from-global` -- `doctor [--fix]` -- health checks; `--fix` auto-installs MCP server binary. Includes a `sync_secrets` check (since 0.55.0): when run inside a sync working tree (`.keboola/manifest.json`), warns if any in-sync config holds plaintext `#`-secrets (#378); `skip` outside a sync tree +- `doctor [--fix]` -- health checks; `--fix` auto-installs MCP server binary. Includes a `sync_secrets` check (since 0.55.0): when run inside a sync working tree (`.keboola/manifest.json`), warns if any in-sync config holds plaintext `#`-secrets (#378); `skip` outside a sync tree. Also an `mcp_tool_tasks` check (since 0.81.0): warns when `agents.json` holds scheduled tasks using the deprecated `mcp_tool` action (removed in v0.85.0), listing each task's tool and native replacement in `details.tasks[]` - `version` -- show version and check for MCP server updates; on a standalone binary it advertises the native channel's upgrade command instead of `kbagent update` (v0.79.0+) - `context` -- full usage instructions for AI agents diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 370feebb..c3af361f 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -34,6 +34,12 @@ Versioning convention: unlike an interactive `tool call` they get no warning at removal time: a scheduled task simply starts failing on its next cron tick. Use the `cli_equivalent` from `kbagent tool list` to rewrite each one. +- **Find the affected tasks (since v0.81.0)**: `kbagent doctor` has an + `mcp_tool_tasks` check that warns with the task ids, the tool each one + calls and its native replacement (`details.tasks[]` in `--json`, each with + `native_command`). `kbagent agent list` marks those rows DEPRECATED and its + `--json` adds an additive per-task `deprecation` key -- present ONLY on + affected tasks, so every other consumer sees a byte-identical payload. ## MCP tool classification is FAIL-CLOSED; parity commands replace `tool call` (since v0.73.0) diff --git a/pyproject.toml b/pyproject.toml index e91a2b63..2c510f1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-cli" -version = "0.80.0" +version = "0.81.0" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 4801fac0..1d691866 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -24,6 +24,25 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.81.0": [ + "New (#390): `kbagent doctor` gains an `mcp_tool_tasks` check that finds scheduled " + "agent tasks still using the deprecated `--type mcp_tool` action, which is removed " + "in v0.85.0. These are the tasks that break silently: an interactive `tool call` " + "warns on every invocation right up to removal, but a scheduled task was warned " + "once -- when it was created -- and then runs unattended, so at removal it simply " + "starts failing on its next cron tick with nobody watching. The check warns with " + "each task's id, the tool it calls and the native command that replaces it " + "(`details.tasks[]` in `--json`, each carrying `native_command`); a tool the parity " + "map does not know reports `null` rather than inventing a replacement. It skips " + "cleanly when there is no `agents.json` and never spawns MCP or calls the API.", + "New (#390): `kbagent agent list` marks tasks using the MCP passthrough. A note under " + "the table names them and points at `kbagent doctor` (an inline tag in the Type " + "column was truncated to 'DEPRECA…' -- that column is one of seven), and `--json` " + "adds an additive per-task " + "`deprecation` key carrying the removal version. The key appears ONLY on affected " + "tasks, so every existing consumer sees a byte-identical payload -- the same " + "contract `tool list` / `tool call` already use.", + ], "0.80.0": [ "Note (#390): the MCP passthrough now has a named removal date -- `kbagent tool " "list` / `tool call` and `agent --type mcp_tool` are REMOVED in **v0.85.0**, " diff --git a/src/keboola_agent_cli/commands/agent.py b/src/keboola_agent_cli/commands/agent.py index fe95b466..5c861d81 100644 --- a/src/keboola_agent_cli/commands/agent.py +++ b/src/keboola_agent_cli/commands/agent.py @@ -236,6 +236,19 @@ def _resolve_id( # ── Output renderers ────────────────────────────────────────────────── +def _annotate_deprecation(task: dict[str, Any]) -> dict[str, Any]: + """Add an additive ``deprecation`` key to a task using the MCP passthrough. + + Additive and only on affected tasks, so every existing ``--json`` consumer + sees a byte-identical payload -- the same contract ``tool list`` / ``tool + call`` already use. Machine consumers can therefore find the tasks that + need migrating without parsing the human table. + """ + if (task.get("action") or {}).get("type") == "mcp_tool": + task["deprecation"] = MCP_TOOL_ACTION_DEPRECATION + return task + + def _render_tasks_table(console: Any, data: dict[str, Any]) -> None: """Plain-text table of tasks: id / name / cron / state.""" tasks = data.get("tasks") or [] @@ -262,17 +275,33 @@ def _render_tasks_table(console: Any, data: dict[str, Any]) -> None: state_bits.append("[green]enabled[/green]") state = " ".join(state_bits) or "-" action = task.get("action") or {} + action_type = action.get("type", "?") table.add_row( str(task.get("id", "")), task.get("name", ""), "" if task.get("manual") else task.get("cron", "") or "-", - action.get("type", "?"), + action_type, state, task.get("last_run_at") or "-", task.get("next_run_at") or "-", ) console.print(table) + # Footnote rather than a marker inside the Type cell: that column is narrow + # enough that Rich truncated the tag to "DEPRECA…", which is worse than not + # flagging it at all. Below the table there is room to name the affected + # tasks AND what to do about them. + deprecated = [t for t in tasks if (t.get("action") or {}).get("type") == "mcp_tool"] + if deprecated: + ids = ", ".join(str(t.get("id", "?")) for t in deprecated) + console.print( + f"[yellow]{len(deprecated)} task(s) use the deprecated 'mcp_tool' action " + f"({ids}) -- REMOVED in kbagent v{MCP_REMOVAL_VERSION} " + f"({MCP_REMOVAL_TARGET_DATE}).[/yellow] They run unattended, so they get no " + f"warning at removal. Run [cyan]kbagent doctor[/cyan] for the native " + f"replacement of each." + ) + def _render_task_detail(console: Any, task: dict[str, Any]) -> None: """Pretty single-task panel with the action payload pretty-printed.""" @@ -513,7 +542,7 @@ def agent_list(ctx: typer.Context) -> None: except ConfigError as exc: formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None - payload = {"tasks": [t.model_dump(mode="json") for t in tasks]} + payload = {"tasks": [_annotate_deprecation(t.model_dump(mode="json")) for t in tasks]} formatter.output(payload, _render_tasks_table) diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index ac7e14a9..da4b963f 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -1280,6 +1280,8 @@ The `tool` group and `agent --type mcp_tool` are REMOVED in kbagent v0.85.0, scheduled for the end of August 2026. Migrate before then. + Run `kbagent doctor` to list scheduled tasks still on `mcp_tool` -- + the mcp_tool_tasks check names each task's tool and native replacement. The MCP passthrough is on a removal track (epic #390): every catalog tool has a native command. Prefer the native command in ALL new work -- `tool list` prints the replacement in the cli_equivalent column and `tool call` diff --git a/src/keboola_agent_cli/services/doctor_service.py b/src/keboola_agent_cli/services/doctor_service.py index 8bcd0368..01182780 100644 --- a/src/keboola_agent_cli/services/doctor_service.py +++ b/src/keboola_agent_cli/services/doctor_service.py @@ -21,10 +21,28 @@ from ..config_store import ConfigStore from ..constants import ENV_CONVERSATION_ID from ..errors import KeboolaApiError +from ..mcp_parity import ( + MCP_REMOVAL_TARGET_DATE, + MCP_REMOVAL_VERSION, + native_equivalent, +) from ..models import AppConfig from .base import ClientFactory, default_client_factory from .mcp_service import McpService, ensure_mcp_installed +# Cap on how many offending items a single check names inline; the rest are +# summarised as "+N more" and the full list travels in `details` for --json. +_MAX_LISTED_TASKS = 5 +AGENTS_FILENAME = "agents.json" + + +def _native_command_for(tool: str | None) -> str | None: + """Native CLI replacement for an MCP tool name, if the parity map knows one.""" + if not tool: + return None + entry = native_equivalent(tool) + return f"kbagent {entry.command}" if entry is not None else None + class DoctorService: """Business logic for health checks. @@ -87,6 +105,9 @@ def run_checks(self) -> dict[str, Any]: sync_secret_check = self._check_sync_secrets() all_checks.append(sync_secret_check) + mcp_tool_task_check = self._check_mcp_tool_tasks() + all_checks.append(mcp_tool_task_check) + # Build summary total = len(all_checks) passed = sum(1 for c in all_checks if c["status"] == "pass") @@ -158,6 +179,83 @@ def _check_sync_secrets(self) -> dict[str, Any]: ), } + def _check_mcp_tool_tasks(self) -> dict[str, Any]: + """Check 9: flag scheduled tasks that use the MCP passthrough (epic #390). + + ``agent --type mcp_tool`` is removed in the version named by + :data:`MCP_REMOVAL_VERSION`. Unlike an interactive ``tool call`` -- which + warns on every invocation right up to removal -- these tasks live in + ``agents.json`` and then run unattended, so nobody is present to be + warned when they break: a cron task simply starts failing. This check is + the standing reminder in the one command people run when something feels + off. Read-only: filesystem only, no API call, no MCP spawn. + """ + # Local import: keeps the server package off the doctor cold-start path. + from ..server.agents_store import AgentStore + + agents_path = self._config_store.config_dir / AGENTS_FILENAME + if not agents_path.exists(): + return { + "check": "mcp_tool_tasks", + "name": "Deprecated mcp_tool agent tasks", + "status": "skip", + "message": f"No {AGENTS_FILENAME} in the config dir -- no agent tasks registered.", + } + + try: + tasks = AgentStore(config_dir=self._config_store.config_dir).load_tasks() + except Exception as exc: + return { + "check": "mcp_tool_tasks", + "name": "Deprecated mcp_tool agent tasks", + "status": "warn", + "message": f"Could not read {AGENTS_FILENAME}: {exc}", + } + + affected = [t for t in tasks if getattr(t.action, "type", None) == "mcp_tool"] + if not affected: + return { + "check": "mcp_tool_tasks", + "name": "Deprecated mcp_tool agent tasks", + "status": "pass", + "message": f"No tasks use the deprecated 'mcp_tool' action ({len(tasks)} checked).", + } + + shown = ", ".join( + f"{t.id} ({t.action.params.get('tool', '?')})" for t in affected[:_MAX_LISTED_TASKS] + ) + more = ( + f" (+{len(affected) - _MAX_LISTED_TASKS} more)" + if len(affected) > _MAX_LISTED_TASKS + else "" + ) + return { + "check": "mcp_tool_tasks", + "name": "Deprecated mcp_tool agent tasks", + "status": "warn", + "message": ( + f"{len(affected)} scheduled task(s) use the deprecated 'mcp_tool' action, " + f"REMOVED in kbagent v{MCP_REMOVAL_VERSION} ({MCP_REMOVAL_TARGET_DATE}): " + f"{shown}{more}. They run unattended, so they get NO warning at removal -- " + f"they just start failing. Migrate each to --type cli_command; " + f"`kbagent tool list` prints the native command per tool." + ), + "details": { + "removal_version": MCP_REMOVAL_VERSION, + "tasks": [ + { + "id": t.id, + "name": t.name, + "tool": t.action.params.get("tool"), + "cron": None if t.manual else t.cron, + "enabled": t.enabled, + "native_command": _native_command_for(t.action.params.get("tool")), + } + for t in affected + ], + }, + } + def _check_config_source(self) -> dict[str, Any]: """Check 0: Report which config source is active.""" return { diff --git a/tests/test_mcp_tool_task_detection.py b/tests/test_mcp_tool_task_detection.py new file mode 100644 index 00000000..a7112b47 --- /dev/null +++ b/tests/test_mcp_tool_task_detection.py @@ -0,0 +1,139 @@ +"""Tests for the mcp_tool task detection surfaces (epic #390 phase 2).""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from keboola_agent_cli.commands.agent import _annotate_deprecation +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.mcp_parity import MCP_REMOVAL_VERSION +from keboola_agent_cli.services.doctor_service import DoctorService + + +def _write_agents(config_dir: Path, tasks: list[dict[str, Any]]) -> None: + config_dir.mkdir(parents=True, exist_ok=True) + # agents.json is a bare JSON list, not an object with a "tasks" key. + (config_dir / "agents.json").write_text(json.dumps(tasks), encoding="utf-8") + + +def _task(task_id: str, action_type: str, **params: Any) -> dict[str, Any]: + return { + "id": task_id, + "name": f"task {task_id}", + "cron": "0 6 * * *", + "enabled": True, + "manual": False, + "action": {"type": action_type, "params": params}, + } + + +def _doctor(config_dir: Path) -> DoctorService: + return DoctorService(ConfigStore(config_dir=config_dir)) + + +class TestDoctorMcpToolTaskCheck: + """`kbagent doctor` is the standing reminder for tasks that break silently.""" + + def test_skips_without_agents_json(self, tmp_path): + result = _doctor(tmp_path)._check_mcp_tool_tasks() + assert result["status"] == "skip" + assert result["check"] == "mcp_tool_tasks" + + def test_passes_when_no_task_uses_mcp_tool(self, tmp_path): + _write_agents(tmp_path, [_task("t1", "cli_command", argv=["job", "list"])]) + result = _doctor(tmp_path)._check_mcp_tool_tasks() + assert result["status"] == "pass" + + def test_warns_and_names_the_removal_version(self, tmp_path): + _write_agents(tmp_path, [_task("t1", "mcp_tool", tool="create_config")]) + result = _doctor(tmp_path)._check_mcp_tool_tasks() + assert result["status"] == "warn" + assert MCP_REMOVAL_VERSION in result["message"] + # The whole point: these run unattended and get no warning at removal. + assert "no warning" in result["message"].lower() + + def test_details_carry_the_native_command_per_task(self, tmp_path): + _write_agents(tmp_path, [_task("t1", "mcp_tool", tool="create_config")]) + result = _doctor(tmp_path)._check_mcp_tool_tasks() + entry = result["details"]["tasks"][0] + assert entry["id"] == "t1" + assert entry["tool"] == "create_config" + assert entry["native_command"] == "kbagent config new" + + def test_unknown_tool_degrades_to_no_native_command(self, tmp_path): + """A tool absent from the parity map must not invent a replacement.""" + _write_agents(tmp_path, [_task("t1", "mcp_tool", tool="no_such_tool")]) + entry = _doctor(tmp_path)._check_mcp_tool_tasks()["details"]["tasks"][0] + assert entry["native_command"] is None + + def test_long_lists_are_truncated_but_complete_in_details(self, tmp_path): + _write_agents( + tmp_path, [_task(f"t{i}", "mcp_tool", tool="create_config") for i in range(8)] + ) + result = _doctor(tmp_path)._check_mcp_tool_tasks() + assert "+3 more" in result["message"] + assert len(result["details"]["tasks"]) == 8 + + def test_unreadable_agents_json_warns_instead_of_crashing(self, tmp_path): + tmp_path.mkdir(parents=True, exist_ok=True) + (tmp_path / "agents.json").write_text("{ not json", encoding="utf-8") + result = _doctor(tmp_path)._check_mcp_tool_tasks() + # AgentStore tolerates a corrupt file by returning no tasks; either way + # doctor must not raise. + assert result["status"] in {"pass", "warn"} + + def test_check_is_registered_in_run_checks(self, tmp_path): + _write_agents(tmp_path, [_task("t1", "mcp_tool", tool="create_config")]) + checks = _doctor(tmp_path).run_checks()["checks"] + assert any(c["check"] == "mcp_tool_tasks" for c in checks) + + +class TestAgentListFooter: + """The marker lives under the table, not inside the Type cell. + + An inline tag was truncated to "DEPRECA…" by Rich -- the Type column is one + of seven and too narrow -- which is worse than not flagging it at all. + """ + + @staticmethod + def _render(tasks: list[dict[str, Any]]) -> str: + from io import StringIO + + from rich.console import Console + + from keboola_agent_cli.commands.agent import _render_tasks_table + + buf = StringIO() + _render_tasks_table(Console(file=buf, width=200, force_terminal=False), {"tasks": tasks}) + return buf.getvalue() + + def test_footer_names_the_task_and_the_version(self): + out = self._render([_task("nightly01", "mcp_tool", tool="create_config")]) + assert "nightly01" in out + assert MCP_REMOVAL_VERSION in out + assert "kbagent doctor" in out + + def test_no_footer_without_mcp_tool_tasks(self): + out = self._render([_task("t1", "cli_command", argv=["job", "list"])]) + assert MCP_REMOVAL_VERSION not in out + + def test_type_cell_is_not_padded_with_a_truncatable_tag(self): + """Guard against re-introducing the inline marker.""" + out = self._render([_task("t1", "mcp_tool", tool="create_config")]) + assert "DEPRECA" not in out.split("REMOVED")[0] + + +class TestAgentListDeprecationKey: + """`--json` consumers must be able to find affected tasks without scraping.""" + + def test_mcp_tool_task_gains_the_key(self): + annotated = _annotate_deprecation(_task("t1", "mcp_tool", tool="create_config")) + assert MCP_REMOVAL_VERSION in annotated["deprecation"] + + def test_other_action_types_are_untouched(self): + """Additive: every existing consumer must see a byte-identical payload.""" + for action_type in ("cli_command", "ai_agent"): + payload = _task("t1", action_type) + assert _annotate_deprecation(dict(payload)) == payload diff --git a/uv.lock b/uv.lock index c4786d8f..17632596 100644 --- a/uv.lock +++ b/uv.lock @@ -590,7 +590,7 @@ wheels = [ [[package]] name = "keboola-cli" -version = "0.80.0" +version = "0.81.0" source = { editable = "." } dependencies = [ { name = "croniter" },