From 08b4e99f7f2f558b7172b0fc8d82373592351a3e Mon Sep 17 00:00:00 2001 From: crmapj Date: Thu, 6 Aug 2026 18:08:52 +0000 Subject: [PATCH 01/12] feat(plugins): ship Agent Plugins v1.0.0 portable package Add root plugin.json and mcp.json so clients that implement the open Agent Plugins standard can discover skills and the stdio MCP server without harness-specific manifests. Keep Claude/Codex packages as additive compatibility layers, honor PLUGIN_ROOT/PLUGIN_DATA in the launcher, and lock the portable version into release bump verification. --- .github/workflows/bump.yml | 3 +- CODING_STANDARDS_AND_STRUCTURE.md | 5 +- README.md | 10 ++ RELEASE.md | 12 +- bin/launcher.sh | 13 ++- mcp.json | 13 +++ plugin.json | 14 +++ pyproject.toml | 6 +- tests/test_agent_plugins.py | 183 ++++++++++++++++++++++++++++++ 9 files changed, 243 insertions(+), 16 deletions(-) create mode 100644 mcp.json create mode 100644 plugin.json create mode 100644 tests/test_agent_plugins.py diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml index e0a96c2a..239eccd6 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -98,10 +98,11 @@ jobs: run: | set -euo pipefail project=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])') + portable=$(jq -r '.version' plugin.json) claude=$(jq -r '.version' .claude-plugin/plugin.json) codex=$(jq -r '.version' .codex-plugin/plugin.json) - for pair in "claude:${claude}" "codex:${codex}"; do + for pair in "portable:${portable}" "claude:${claude}" "codex:${codex}"; do name=${pair%%:*} found=${pair#*:} if [ "${found}" != "${project}" ]; then diff --git a/CODING_STANDARDS_AND_STRUCTURE.md b/CODING_STANDARDS_AND_STRUCTURE.md index 30b121c3..7524f3e8 100644 --- a/CODING_STANDARDS_AND_STRUCTURE.md +++ b/CODING_STANDARDS_AND_STRUCTURE.md @@ -32,6 +32,7 @@ export DKU_API_KEY="your-api-key" | Workflow prompts | `dataiku_mcp/prompts/workflows.py` | | Project/dataset/folder/recipe/ML skills | `skills/**/SKILL.md` | | Cobuild conversation tools | `dataiku_mcp/tools/cobuild.py` | +| Portable Agent Plugins package | root `plugin.json` + `mcp.json` (keep harness manifests in sync for MCP launch) | ## Error Handling - Prefer simple, readable tool handlers: keep top-level control flow short, avoid repeated Dataiku lookups, and use local helpers only when they improve clarity. @@ -94,9 +95,9 @@ uv run --quiet ./bin/run_mcp.py # skip the launcher, straight to the server There are two files, and the split matters: - **`bin/run_mcp.py`** is the server entry point. It carries [PEP 723](https://peps.python.org/pep-0723/) inline metadata — pinned dependencies and `requires-python` — so uv can build its runtime environment with no project install. -- **`bin/launcher.sh`** is what every manifest (`.mcp.json`, `.claude-plugin`, `.codex-plugin`) actually runs, and the only launcher. It picks a runtime in three tiers and `exec`s the server on the first that works, or exits non-zero with install instructions: +- **`bin/launcher.sh`** is what every manifest (`mcp.json`, `.mcp.json`, `.claude-plugin`, `.codex-plugin`, root Agent Plugins package) actually runs, and the only launcher. It picks a runtime in three tiers and `exec`s the server on the first that works, or exits non-zero with install instructions: 1. `uv run`, if a `uv` on `PATH` answers `uv --version`. - 2. A venv under `${CLAUDE_PLUGIN_DATA}` with the pinned dependencies pip-installed into it, built by the first interpreter that satisfies `requires-python`. Candidates are deduplicated by resolved path, so aliases of one broken interpreter are not retried a dozen times. + 2. A venv under `${PLUGIN_DATA}` (falling back to `${CLAUDE_PLUGIN_DATA}`, then `$PLUGIN_ROOT/.deps`) with the pinned dependencies pip-installed into it, built by the first interpreter that satisfies `requires-python`. Candidates are deduplicated by resolved path, so aliases of one broken interpreter are not retried a dozen times. 3. `@dataiku/uv@0.12.0` through `npx` or `pnpx`, probed with `--help` — a runner on `PATH` still has to be able to fetch the package. It is shell rather than Python because a launcher cannot be written in the language it is searching for: `/bin/sh` exists on hosts that have no `python3` at all. Any tier that cannot provision falls through to the next, so a host with only Python, or only Node, still starts. diff --git a/README.md b/README.md index c535f56d..e374dcef 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ Each plugin bundles the skills and starts the same local `stdio` MCP server. The `dataiku-headless` is not published to PyPI; it's installed as a harness plugin or run from a checkout. Either way the harness runs `bin/launcher.sh`, which provisions the runtime with whatever the host already has: [uv](https://docs.astral.sh/uv/) if it's on your `PATH`, otherwise a `pip` virtualenv built by any Python 3.10+, otherwise `uv` borrowed through `npx`/`pnpx`. Nothing needs to be installed up front, and only one of those three has to be present. +### Agent Plugins (portable) + +This repository is an [Agent Plugins](https://agent-plugins.org/) v1.0.0 package: root `plugin.json`, root `mcp.json`, and Agent Skills under `skills/`. Any client that implements the standard can load the portable core directly from this directory. + +Harness-specific manifests (`.claude-plugin/`, `.codex-plugin/`, …) remain for install paths those clients already support. They are additive compatibility layers; the portable files are the cross-client floor. + ### Claude Code CLI ```bash @@ -97,6 +103,8 @@ Add the following to your `.mcp.json` to enable the Dataiku MCP server for any a } ``` +Portable Agent Plugins clients read root `mcp.json` instead (stdio server `dataiku`, launched via `sh ${PLUGIN_ROOT}/bin/launcher.sh`). + #### Skills The skills/*/SKILL.md files follow the universal skill format and work with any tool that reads it. @@ -225,6 +233,8 @@ uv run dataiku-headless ├── bin/ │ ├── launcher.sh # What the manifests run: picks uv → python venv → npx/pnpx uv, then execs the server │ └── run_mcp.py # Server entry point: PEP 723 script pinning the runtime deps inline +├── plugin.json # Agent Plugins v1.0.0 portable manifest +├── mcp.json # Agent Plugins portable stdio MCP config ├── .claude-plugin/ │ ├── plugin.json # Claude Code plugin manifest (skills + unconfigured stdio MCP) │ └── marketplace.json # Marketplace catalog (single-plugin, source: "./") diff --git a/RELEASE.md b/RELEASE.md index 59cc1f78..11327858 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -18,11 +18,13 @@ artifacts: | GitHub release | The published, browsable release notes | The version number still matters even without an index: Commitizen keeps it in -lockstep across `pyproject.toml` and the three plugin manifests, and the manifest -version is how a harness notices there's a newer plugin to install. `bump.yml` -verifies that lockstep held before it tags anything — a `version_files` entry -whose version string stops matching is skipped *silently* by Commitizen, which -would otherwise ship a release whose manifests still advertise the old version. +lockstep across `pyproject.toml` and the plugin manifests (portable Agent Plugins +`plugin.json`, plus the Claude Code and Codex compatibility manifests), and the +manifest version is how a harness notices there's a newer plugin to install. +`bump.yml` verifies that lockstep held before it tags anything — a +`version_files` entry whose version string stops matching is skipped *silently* +by Commitizen, which would otherwise ship a release whose manifests still +advertise the old version. --- diff --git a/bin/launcher.sh b/bin/launcher.sh index c7434f0e..5f8e8206 100644 --- a/bin/launcher.sh +++ b/bin/launcher.sh @@ -10,7 +10,7 @@ # # 1. uv on PATH — `uv run` resolves run_mcp.py's PEP 723 block itself. # 2. python3 >= the block's requires-python — build a venv under -# $CLAUDE_PLUGIN_DATA and pip-install the same dependencies into it. +# $PLUGIN_DATA (or Claude/local fallbacks) and pip-install deps into it. # 3. npx or pnpx — borrow uv from npm without installing anything. # # The first tier that works becomes the server process. If none do, we exit @@ -25,11 +25,12 @@ SERVER="$HERE/run_mcp.py" NPM_UV_PACKAGE="@dataiku/uv@0.12.0" NPM_RUNNERS="npx pnpx" -# CLAUDE_PLUGIN_DATA is the harness-provided directory that survives plugin -# updates — the documented home for exactly this kind of generated venv. Outside -# a plugin install it falls back to a dot-dir beside the checkout. -PLUGIN_ROOT=${CLAUDE_PLUGIN_ROOT:-$(CDPATH='' cd -- "$HERE/.." && pwd)} -DATA_DIR=${CLAUDE_PLUGIN_DATA:-$PLUGIN_ROOT/.deps} +# Persistent state directory (venv, caches). Preference order: +# 1. PLUGIN_DATA / PLUGIN_ROOT — Agent Plugins standard vars +# 2. CLAUDE_PLUGIN_DATA / CLAUDE_PLUGIN_ROOT — Claude Code plugin install +# 3. Local checkout defaults (repo root + .deps/) +PLUGIN_ROOT=${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-$(CDPATH='' cd -- "$HERE/.." && pwd)}} +DATA_DIR=${PLUGIN_DATA:-${CLAUDE_PLUGIN_DATA:-$PLUGIN_ROOT/.deps}} VENV_DIR="$DATA_DIR/venv" VENV_PYTHON="$VENV_DIR/bin/python" VENV_MARKER="$VENV_DIR/.installed" diff --git a/mcp.json b/mcp.json new file mode 100644 index 00000000..c354c3eb --- /dev/null +++ b/mcp.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "dataiku": { + "type": "stdio", + "command": "sh", + "args": ["${PLUGIN_ROOT}/bin/launcher.sh"], + "env": { + "UV_CACHE_DIR": "${PLUGIN_DATA}/uv-cache" + } + } + } +} diff --git a/plugin.json b/plugin.json new file mode 100644 index 00000000..d753df83 --- /dev/null +++ b/plugin.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "dataiku-headless", + "version": "0.2.0", + "description": "Connect your agent to Dataiku: inspect projects, datasets, recipes, ML, and agents with typed MCP tools, and drive Dataiku Cobuild to build project-level assets.", + "author": { + "name": "Dataiku", + "url": "https://www.dataiku.com/" + }, + "homepage": "https://github.com/dataiku/dataiku-headless", + "repository": "https://github.com/dataiku/dataiku-headless", + "license": "Apache-2.0", + "keywords": ["dataiku", "cobuild", "mcp", "agent"] +} diff --git a/pyproject.toml b/pyproject.toml index df1d79f4..757637e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,9 +73,11 @@ tag_format = "v$version" update_changelog_on_bump = true major_version_zero = true # Keep the plugin manifests' "version" field in lockstep with [project].version. +# Match the version key specifically so schema URLs (…/1.0.0/…) are not rewritten. version_files = [ - ".claude-plugin/plugin.json", - ".codex-plugin/plugin.json", + "plugin.json:\"version\":", + ".claude-plugin/plugin.json:\"version\":", + ".codex-plugin/plugin.json:\"version\":", ] [dependency-groups] diff --git a/tests/test_agent_plugins.py b/tests/test_agent_plugins.py new file mode 100644 index 00000000..4fe4c2e1 --- /dev/null +++ b/tests/test_agent_plugins.py @@ -0,0 +1,183 @@ +"""Agent Plugins v1.0.0 portable package contract. + +This repo ships as an Agent Plugins package (root ``plugin.json`` + ``mcp.json`` ++ ``skills/``) while retaining harness-specific manifests under +``.claude-plugin/`` and ``.codex-plugin/``. These tests pin the portable floor +and keep version fields in lockstep with ``[project].version``. +""" + +from __future__ import annotations + +import json +import re +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" +MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json" + +# Closed portable manifest fields (Agent Plugins §5.2). +PLUGIN_TOP_LEVEL = { + "$schema", + "name", + "version", + "description", + "author", + "homepage", + "repository", + "license", + "keywords", + "extensions", +} + +# Plugin name constraints (Agent Plugins §5.5). +PLUGIN_NAME_RE = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$") + + +def _load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _project_version() -> str: + data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + return data["project"]["version"] + + +def test_portable_plugin_manifest_is_agent_plugins_v1(): + manifest = _load_json(ROOT / "plugin.json") + + assert set(manifest) <= PLUGIN_TOP_LEVEL + assert manifest["$schema"] == PLUGIN_SCHEMA + assert isinstance(manifest["name"], str) + assert 1 <= len(manifest["name"]) <= 64 + assert PLUGIN_NAME_RE.fullmatch(manifest["name"]), manifest["name"] + assert manifest["name"] == "dataiku-headless" + assert isinstance(manifest.get("version"), str) and manifest["version"] + assert isinstance(manifest.get("description"), str) and manifest["description"] + assert isinstance(manifest.get("license"), str) and manifest["license"] + assert isinstance(manifest.get("keywords"), list) + assert all(isinstance(k, str) for k in manifest["keywords"]) + + author = manifest.get("author") + if author is not None: + assert isinstance(author, dict) + assert set(author) <= {"name", "email", "url"} + assert all(isinstance(v, str) for v in author.values()) + + +def test_portable_mcp_config_is_agent_plugins_v1_stdio(): + config = _load_json(ROOT / "mcp.json") + + assert set(config) == {"$schema", "mcpServers"} + assert config["$schema"] == MCP_SCHEMA + assert isinstance(config["mcpServers"], dict) + assert "dataiku" in config["mcpServers"] + + server = config["mcpServers"]["dataiku"] + assert set(server) <= {"type", "command", "args", "env", "cwd"} + assert server["type"] == "stdio" + assert server["command"] == "sh" + assert isinstance(server.get("args"), list) + assert server["args"] == ["${PLUGIN_ROOT}/bin/launcher.sh"] + + env = server.get("env", {}) + assert isinstance(env, dict) + assert all(isinstance(k, str) and isinstance(v, str) for k, v in env.items()) + # Reserved names are client-supplied only (Agent Plugins §9.2). + assert "PLUGIN_ROOT" not in env + assert "PLUGIN_DATA" not in env + assert env.get("UV_CACHE_DIR") == "${PLUGIN_DATA}/uv-cache" + + cwd = server.get("cwd") + if cwd is not None: + assert cwd.startswith(("./", "${PLUGIN_ROOT}", "${PLUGIN_DATA}")) + + +def test_plugin_and_mcp_schema_versions_match(): + plugin = _load_json(ROOT / "plugin.json") + mcp = _load_json(ROOT / "mcp.json") + plugin_version = plugin["$schema"].rsplit("/", 2)[1] + mcp_version = mcp["$schema"].rsplit("/", 2)[1] + assert plugin_version == mcp_version == "1.0.0" + + +def test_skill_is_discovered_as_immediate_child_of_skills(): + skill_md = ROOT / "skills" / "dataiku-headless" / "SKILL.md" + assert skill_md.is_file() + # Agent Plugins discovers only immediate children of skills/; nested + # SKILL.md under references/ must not appear as sibling skills. + skill_dirs = [ + p for p in (ROOT / "skills").iterdir() if p.is_dir() and (p / "SKILL.md").is_file() + ] + assert [p.name for p in skill_dirs] == ["dataiku-headless"] + + +def test_plugin_versions_match_project_version(): + expected = _project_version() + portable = _load_json(ROOT / "plugin.json")["version"] + claude = _load_json(ROOT / ".claude-plugin" / "plugin.json")["version"] + codex = _load_json(ROOT / ".codex-plugin" / "plugin.json")["version"] + assert portable == claude == codex == expected + + +def test_launcher_prefers_agent_plugins_data_dir(tmp_path): + """PLUGIN_DATA / PLUGIN_ROOT win over Claude-specific and local defaults.""" + import os + import subprocess + + launcher = (ROOT / "bin" / "launcher.sh").read_text(encoding="utf-8") + # Extract the real assignment lines so this test cannot drift from launcher.sh. + match = re.search( + r"^PLUGIN_ROOT=\$\{PLUGIN_ROOT:-.*\nDATA_DIR=\$\{PLUGIN_DATA:-.*$", + launcher, + re.MULTILINE, + ) + assert match, "launcher.sh lost PLUGIN_ROOT/DATA_DIR assignment order" + probe = tmp_path / "probe.sh" + probe.write_text( + "set -eu\n" + 'HERE=$(CDPATH=\'\' cd -- "$(dirname -- "$0")" && pwd)\n' + f"{match.group(0)}\n" + 'printf \'%s\\n\' "$PLUGIN_ROOT"\n' + 'printf \'%s\\n\' "$DATA_DIR"\n', + encoding="utf-8", + ) + probe.chmod(0o755) + + env = os.environ.copy() + for key in ( + "PLUGIN_ROOT", + "PLUGIN_DATA", + "CLAUDE_PLUGIN_ROOT", + "CLAUDE_PLUGIN_DATA", + ): + env.pop(key, None) + + agent_root = tmp_path / "agent-root" + agent_data = tmp_path / "agent-data" + claude_root = tmp_path / "claude-root" + claude_data = tmp_path / "claude-data" + for path in (agent_root, agent_data, claude_root, claude_data): + path.mkdir() + + env.update( + { + "PLUGIN_ROOT": str(agent_root), + "PLUGIN_DATA": str(agent_data), + "CLAUDE_PLUGIN_ROOT": str(claude_root), + "CLAUDE_PLUGIN_DATA": str(claude_data), + } + ) + out = subprocess.check_output(["sh", str(probe)], env=env, text=True) + root, data = out.splitlines() + assert root == str(agent_root) + assert data == str(agent_data) + + env.pop("PLUGIN_ROOT") + env.pop("PLUGIN_DATA") + out = subprocess.check_output(["sh", str(probe)], env=env, text=True) + root, data = out.splitlines() + assert root == str(claude_root) + assert data == str(claude_data) From ecaa9dab090d05692877d19d16e8665faefb5f99 Mon Sep 17 00:00:00 2001 From: crmapj Date: Thu, 6 Aug 2026 18:28:35 +0000 Subject: [PATCH 02/12] fix(plugins): harden Agent Plugins packaging after review Address Sol/xhigh adversarial findings: drop tomllib so 3.10 CI can collect tests, verify manifests before pushing release tags, lock schema URL rewrites, and cover version-selector + local-fallback cases. --- .github/workflows/bump.yml | 29 ++++++++++++++- AGENTS.md | 3 +- README.md | 4 +-- RELEASE.md | 9 ++--- tests/test_agent_plugins.py | 70 ++++++++++++++++++++++++++++++++++--- 5 files changed, 102 insertions(+), 13 deletions(-) diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml index 239eccd6..94c6ef60 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -67,10 +67,14 @@ jobs: - id: before name: Record current tag run: echo "tag=$(git describe --tags --abbrev=0 2>/dev/null || true)" >> "$GITHUB_OUTPUT" + # Keep the bump local until manifests are verified. commitizen-action's + # push defaults to true, which would publish a release tag before we can + # fail on a silent version_files miss. - name: Bump version, changelog and tag uses: commitizen-tools/commitizen-action@338bbd841b75aaee6bf5340e1fa12f6ab58ff9ff # 0.27.1 with: github_token: ${{ secrets.GITHUB_TOKEN }} + push: false # 3 = no commits found, 21 = nothing to bump: treat both as a no-op # rather than a failed run. no_raise: "3,21" @@ -92,7 +96,7 @@ jobs: # but a version_files entry whose version string no longer matches is # skipped *silently* — the run stays green while the plugin manifests keep # advertising the old version, which is exactly the field harnesses use to - # decide whether users get an update. Fail loudly instead. + # decide whether users get an update. Fail loudly instead, before any push. - name: Verify plugin manifests carry the bumped version id: version run: | @@ -123,8 +127,31 @@ jobs: exit 1 fi + # Guard the Agent Plugins schema identifiers: a botched version rewrite + # must not rewrite the 1.0.0 schema path segment. + for schema_file in plugin.json mcp.json; do + schema=$(jq -r '."$schema"' "${schema_file}") + case "${schema}" in + https://agent-plugins.org/schemas/1.0.0/*) ;; + *) + echo "::error::${schema_file} \$schema drifted to ${schema}." + exit 1 + ;; + esac + done + echo "version=${project}" >> "${GITHUB_OUTPUT}" + # Publish the local bump commit + vX.Y.Z tag only after verification. + - name: Push bump commit and version tag + if: steps.after.outputs.tag != '' + env: + TAG: ${{ steps.after.outputs.tag }} + run: | + set -euo pipefail + git push origin "HEAD:${{ github.ref_name }}" + git push origin "refs/tags/${TAG}" + # Plugin release tag, in the form `claude plugin tag` produces. Separate # from commitizen's vX.Y.Z: it marks the commit a harness resolves a plugin # install to. Derived from the tag commitizen actually created, so the two diff --git a/AGENTS.md b/AGENTS.md index 274de331..b45e59f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Use this file when changing this repository. It is not an operating guide for us - Tool registration imports: `dataiku_mcp/__init__.py`. - Project dependencies, Python support, version, and CLI entry points: `pyproject.toml`. - Standalone server dependency pins and Python floor: the PEP 723 block in `bin/run_mcp.py`. -- Plugin launch configuration: `.mcp.json`, `.claude-plugin/plugin.json`, and `.codex-plugin/plugin.json`. +- Portable Agent Plugins package: root `plugin.json` + `mcp.json` (skills under `skills/`). Harness-specific launch config: `.mcp.json`, `.claude-plugin/plugin.json`, and `.codex-plugin/plugin.json`. - User-facing installation and architecture overview: `README.md`. - Release behavior: `RELEASE.md` and `.github/workflows/bump.yml`. - CI behavior: `.github/workflows/ci.yml` and `.github/workflows/pr-title.yml`. @@ -76,6 +76,7 @@ Useful focused checks include: ```bash uv run pytest tests/test_tool_surface.py uv run pytest tests/test_pep723_launcher.py +uv run pytest tests/test_agent_plugins.py uv run pytest tests/test_cobuild.py ``` diff --git a/README.md b/README.md index e374dcef..78de5a4d 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,9 @@ Each plugin bundles the skills and starts the same local `stdio` MCP server. The ### Agent Plugins (portable) -This repository is an [Agent Plugins](https://agent-plugins.org/) v1.0.0 package: root `plugin.json`, root `mcp.json`, and Agent Skills under `skills/`. Any client that implements the standard can load the portable core directly from this directory. +This repository is an [Agent Plugins](https://agent-plugins.org/) v1.0.0 package: root `plugin.json`, root `mcp.json`, and Agent Skills under `skills/`. Clients that implement the standard can load the portable core directly from this directory (stdio MCP currently assumes a POSIX host with `sh`, matching the existing Claude/Codex launch path). -Harness-specific manifests (`.claude-plugin/`, `.codex-plugin/`, …) remain for install paths those clients already support. They are additive compatibility layers; the portable files are the cross-client floor. +Harness-specific manifests (`.claude-plugin/`, `.codex-plugin/`, …) remain for install paths those clients already support. They are parallel legacy packaging, not reverse-domain Agent Plugins extension directories; the portable files are the cross-client floor. ### Claude Code CLI diff --git a/RELEASE.md b/RELEASE.md index 11327858..db7d57a0 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -21,10 +21,11 @@ The version number still matters even without an index: Commitizen keeps it in lockstep across `pyproject.toml` and the plugin manifests (portable Agent Plugins `plugin.json`, plus the Claude Code and Codex compatibility manifests), and the manifest version is how a harness notices there's a newer plugin to install. -`bump.yml` verifies that lockstep held before it tags anything — a -`version_files` entry whose version string stops matching is skipped *silently* -by Commitizen, which would otherwise ship a release whose manifests still -advertise the old version. +`bump.yml` bumps locally first (`push: false`), verifies that lockstep held +across all manifests (and that Agent Plugins `$schema` URLs were not rewritten), +and only then pushes the bump commit and tags — a `version_files` entry whose +version string stops matching is skipped *silently* by Commitizen, which would +otherwise ship a release whose manifests still advertise the old version. --- diff --git a/tests/test_agent_plugins.py b/tests/test_agent_plugins.py index 4fe4c2e1..98e8b63f 100644 --- a/tests/test_agent_plugins.py +++ b/tests/test_agent_plugins.py @@ -8,9 +8,9 @@ from __future__ import annotations +import importlib.metadata import json import re -import tomllib from pathlib import Path ROOT = Path(__file__).resolve().parent.parent @@ -35,15 +35,19 @@ # Plugin name constraints (Agent Plugins §5.5). PLUGIN_NAME_RE = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$") +# cwd forms allowed by Agent Plugins §7.2.1 (stdio). +_CWD_RE = re.compile( + r"^(?:\./(?!\.\.)|\$\{PLUGIN_ROOT\}(?:/|$)|\$\{PLUGIN_DATA\}(?:/|$))" +) + def _load_json(path: Path) -> dict: return json.loads(path.read_text(encoding="utf-8")) def _project_version() -> str: - data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) - return data["project"]["version"] - + # Prefer the installed distribution so this works on Python 3.10 (no tomllib). + return importlib.metadata.version("dataiku-headless") def test_portable_plugin_manifest_is_agent_plugins_v1(): manifest = _load_json(ROOT / "plugin.json") @@ -92,7 +96,8 @@ def test_portable_mcp_config_is_agent_plugins_v1_stdio(): cwd = server.get("cwd") if cwd is not None: - assert cwd.startswith(("./", "${PLUGIN_ROOT}", "${PLUGIN_DATA}")) + assert _CWD_RE.match(cwd), cwd + assert ".." not in cwd def test_plugin_and_mcp_schema_versions_match(): @@ -113,6 +118,24 @@ def test_skill_is_discovered_as_immediate_child_of_skills(): ] assert [p.name for p in skill_dirs] == ["dataiku-headless"] + frontmatter = skill_md.read_text(encoding="utf-8").split("---", 2) + assert len(frontmatter) >= 3, "SKILL.md missing YAML frontmatter" + assert re.search(r"(?m)^name:\s*dataiku-headless\s*$", frontmatter[1]) + assert re.search(r"(?m)^description:\s*\S", frontmatter[1]) + + +def test_mcp_launcher_path_exists_in_package(): + """Portable mcp.json must point at a real package path after expansion.""" + config = _load_json(ROOT / "mcp.json") + server = config["mcpServers"]["dataiku"] + for arg in server.get("args", []): + # Expand only the placeholders this package uses. + expanded = arg.replace("${PLUGIN_ROOT}", str(ROOT)).replace( + "${PLUGIN_DATA}", str(ROOT / ".deps") + ) + if expanded.endswith("launcher.sh"): + assert Path(expanded).is_file(), expanded + def test_plugin_versions_match_project_version(): expected = _project_version() @@ -122,6 +145,29 @@ def test_plugin_versions_match_project_version(): assert portable == claude == codex == expected +def test_commitizen_version_selector_preserves_schema_urls(): + """Simulate commitizen's path:pattern rewrite so schema 1.0.0 is not clobbered.""" + # Mirrors commitizen.bump.update_version_in_files: replace only on lines that + # match the configured regex (here the version key). + pattern = re.compile(r'"version":') + text = (ROOT / "plugin.json").read_text(encoding="utf-8") + current = _project_version() + # Force a synthetic package version that collides with the schema segment. + synthetic = text.replace(f'"version": "{current}"', '"version": "1.0.0"', 1) + assert '"version": "1.0.0"' in synthetic + assert PLUGIN_SCHEMA in synthetic + + rewritten = [] + for line in synthetic.splitlines(keepends=True): + if pattern.search(line): + rewritten.append(line.replace("1.0.0", "1.0.1")) + else: + rewritten.append(line) + result = "".join(rewritten) + assert '"version": "1.0.1"' in result + assert PLUGIN_SCHEMA in result # schema URL must keep 1.0.0 + + def test_launcher_prefers_agent_plugins_data_dir(tmp_path): """PLUGIN_DATA / PLUGIN_ROOT win over Claude-specific and local defaults.""" import os @@ -181,3 +227,17 @@ def test_launcher_prefers_agent_plugins_data_dir(tmp_path): root, data = out.splitlines() assert root == str(claude_root) assert data == str(claude_data) + + # Local checkout fallback when no harness vars are set. + env.pop("CLAUDE_PLUGIN_ROOT") + env.pop("CLAUDE_PLUGIN_DATA") + # Put the probe under a fake bin/ so HERE/.. resolves like launcher.sh. + fake_bin = tmp_path / "checkout" / "bin" + fake_bin.mkdir(parents=True) + local_probe = fake_bin / "probe.sh" + local_probe.write_text(probe.read_text(encoding="utf-8"), encoding="utf-8") + local_probe.chmod(0o755) + out = subprocess.check_output(["sh", str(local_probe)], env=env, text=True) + root, data = out.splitlines() + assert root == str(tmp_path / "checkout") + assert data == str(tmp_path / "checkout" / ".deps") From 778554b1f74174874aa480c0ac921b8e579babc2 Mon Sep 17 00:00:00 2001 From: crmapj Date: Thu, 6 Aug 2026 19:08:54 +0000 Subject: [PATCH 03/12] style: ruff-format agent plugins tests CI pre-commit failed because ruff-format rewrote tests/test_agent_plugins.py. --- tests/test_agent_plugins.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_agent_plugins.py b/tests/test_agent_plugins.py index 98e8b63f..350b2720 100644 --- a/tests/test_agent_plugins.py +++ b/tests/test_agent_plugins.py @@ -49,6 +49,7 @@ def _project_version() -> str: # Prefer the installed distribution so this works on Python 3.10 (no tomllib). return importlib.metadata.version("dataiku-headless") + def test_portable_plugin_manifest_is_agent_plugins_v1(): manifest = _load_json(ROOT / "plugin.json") @@ -114,7 +115,9 @@ def test_skill_is_discovered_as_immediate_child_of_skills(): # Agent Plugins discovers only immediate children of skills/; nested # SKILL.md under references/ must not appear as sibling skills. skill_dirs = [ - p for p in (ROOT / "skills").iterdir() if p.is_dir() and (p / "SKILL.md").is_file() + p + for p in (ROOT / "skills").iterdir() + if p.is_dir() and (p / "SKILL.md").is_file() ] assert [p.name for p in skill_dirs] == ["dataiku-headless"] @@ -186,8 +189,8 @@ def test_launcher_prefers_agent_plugins_data_dir(tmp_path): "set -eu\n" 'HERE=$(CDPATH=\'\' cd -- "$(dirname -- "$0")" && pwd)\n' f"{match.group(0)}\n" - 'printf \'%s\\n\' "$PLUGIN_ROOT"\n' - 'printf \'%s\\n\' "$DATA_DIR"\n', + "printf '%s\\n' \"$PLUGIN_ROOT\"\n" + "printf '%s\\n' \"$DATA_DIR\"\n", encoding="utf-8", ) probe.chmod(0o755) From 1e27d174101a3ea1d138ea669a1b6885bfc87473 Mon Sep 17 00:00:00 2001 From: crmapj Date: Thu, 6 Aug 2026 19:11:59 +0000 Subject: [PATCH 04/12] ci: re-trigger checks after ruff-format fix From 50373efa26a6da9f88a088df3894b40546409cd1 Mon Sep 17 00:00:00 2001 From: crmapj Date: Thu, 6 Aug 2026 18:08:52 +0000 Subject: [PATCH 05/12] feat(plugins): ship Agent Plugins v1.0.0 portable package Add root plugin.json and mcp.json so clients that implement the open Agent Plugins standard can discover skills and the stdio MCP server without harness-specific manifests. Keep Claude/Codex packages as additive compatibility layers, honor PLUGIN_ROOT/PLUGIN_DATA in the launcher, and lock the portable version into release bump verification. --- .github/workflows/bump.yml | 3 +- CODING_STANDARDS_AND_STRUCTURE.md | 1 + README.md | 20 +++- RELEASE.md | 12 +- mcp.json | 11 ++ plugin.json | 14 +++ pyproject.toml | 6 +- tests/test_agent_plugins.py | 183 ++++++++++++++++++++++++++++++ 8 files changed, 237 insertions(+), 13 deletions(-) create mode 100644 mcp.json create mode 100644 plugin.json create mode 100644 tests/test_agent_plugins.py diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml index f5ef138b..18d6fdab 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -115,10 +115,11 @@ jobs: run: | set -euo pipefail project=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])') + portable=$(jq -r '.version' plugin.json) claude=$(jq -r '.version' .claude-plugin/plugin.json) codex=$(jq -r '.version' .codex-plugin/plugin.json) - for pair in "claude:${claude}" "codex:${codex}"; do + for pair in "portable:${portable}" "claude:${claude}" "codex:${codex}"; do name=${pair%%:*} found=${pair#*:} if [ "${found}" != "${project}" ]; then diff --git a/CODING_STANDARDS_AND_STRUCTURE.md b/CODING_STANDARDS_AND_STRUCTURE.md index cf3ca657..12bdd65f 100644 --- a/CODING_STANDARDS_AND_STRUCTURE.md +++ b/CODING_STANDARDS_AND_STRUCTURE.md @@ -32,6 +32,7 @@ export DKU_API_KEY="your-api-key" | Workflow prompts | `dataiku_mcp/prompts/workflows.py` | | Project/dataset/folder/recipe/ML skills | `skills/**/SKILL.md` | | Cobuild conversation tools | `dataiku_mcp/tools/cobuild.py` | +| Portable Agent Plugins package | root `plugin.json` + `mcp.json` (keep harness manifests in sync for MCP launch) | ## Error Handling - Prefer simple, readable tool handlers: keep top-level control flow short, avoid repeated Dataiku lookups, and use local helpers only when they improve clarity. diff --git a/README.md b/README.md index 57225cc1..dc4bc8f8 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,12 @@ codex plugin marketplace add https://github.com/dataiku/dataiku-headless.git codex plugin add dataiku-headless@dataiku ``` +### Agent Plugins (portable) + +This repository is an [Agent Plugins](https://agent-plugins.org/) v1.0.0 package: root `plugin.json`, root `mcp.json`, and Agent Skills under `skills/`. Any client that implements the standard can load the portable core directly from this directory. + +Harness-specific manifests (`.claude-plugin/`, `.codex-plugin/`, …) remain for install paths those clients already support. They are additive compatibility layers; the portable files are the cross-client floor. + ### Claude Code CLI ```bash @@ -77,12 +83,12 @@ claude plugin install dataiku-headless@dataiku grok plugin install dataiku/dataiku-headless --trust ``` -### Cursor Agent CLI +### Cursor -```bash -cursor agent plugin marketplace add github.com/dataiku/dataiku-headless -# Tip: use /plugins in interactive mode to install `dataiku-headless` plugin from this marketplace. -``` +Open **Customize** in the Cursor sidebar, add this GitHub repository as a +plugin source, then install `dataiku-headless` at your preferred user or project +scope. Cursor detects the root Agent Plugins manifest and loads the bundled +skills and MCP server. ### Snowflake CoCo @@ -108,6 +114,8 @@ Add the following to your `.mcp.json` from a checkout of this repository: } ``` +Portable Agent Plugins clients read root `mcp.json` instead. It launches the same locked `uv` script entry point as the existing manifests. + #### Skills The `skills/*/SKILL.md` files follow the universal skill format: @@ -255,6 +263,8 @@ uv run --quiet --locked --script ./bin/run_mcp.py # same command the plugin ma │ ├── launcher.sh # Inactive legacy fallback retained for possible future use │ ├── run_mcp.py # Server entry point: PEP 723 script pinning the runtime deps inline │ └── run_mcp.py.lock # Committed, full dependency resolution for the entry point +├── plugin.json # Agent Plugins v1.0.0 portable manifest +├── mcp.json # Agent Plugins portable stdio MCP config ├── .claude-plugin/ │ ├── plugin.json # Claude Code plugin manifest (skills + unconfigured stdio MCP) │ └── marketplace.json # Marketplace catalog (single-plugin, source: "./") diff --git a/RELEASE.md b/RELEASE.md index 50e0ce2c..41058c61 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -18,11 +18,13 @@ artifacts: | GitHub release | The published, browsable release notes | The version number still matters even without an index: Commitizen keeps it in -lockstep across `pyproject.toml` and the three plugin manifests, and the manifest -version is how a harness notices there's a newer plugin to install. `bump.yml` -verifies that lockstep held before it tags anything — a `version_files` entry -whose version string stops matching is skipped *silently* by Commitizen, which -would otherwise ship a release whose manifests still advertise the old version. +lockstep across `pyproject.toml` and the plugin manifests (portable Agent Plugins +`plugin.json`, plus the Claude Code and Codex compatibility manifests), and the +manifest version is how a harness notices there's a newer plugin to install. +`bump.yml` verifies that lockstep held before it tags anything — a +`version_files` entry whose version string stops matching is skipped *silently* +by Commitizen, which would otherwise ship a release whose manifests still +advertise the old version. --- diff --git a/mcp.json b/mcp.json new file mode 100644 index 00000000..117029a8 --- /dev/null +++ b/mcp.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "dataiku": { + "type": "stdio", + "command": "uv", + "args": ["run", "--quiet", "--locked", "--script", "./bin/run_mcp.py"], + "cwd": "${PLUGIN_ROOT}" + } + } +} diff --git a/plugin.json b/plugin.json new file mode 100644 index 00000000..ff27d9b2 --- /dev/null +++ b/plugin.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "dataiku-headless", + "version": "0.3.0", + "description": "Connect your agent to Dataiku: inspect projects, datasets, recipes, ML, and agents with typed MCP tools, and drive Dataiku Cobuild to build project-level assets.", + "author": { + "name": "Dataiku", + "url": "https://www.dataiku.com/" + }, + "homepage": "https://github.com/dataiku/dataiku-headless", + "repository": "https://github.com/dataiku/dataiku-headless", + "license": "Apache-2.0", + "keywords": ["dataiku", "cobuild", "mcp", "agent"] +} diff --git a/pyproject.toml b/pyproject.toml index 317e464e..05adc34d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,9 +67,11 @@ tag_format = "v$version" update_changelog_on_bump = true major_version_zero = true # Keep the plugin manifests' "version" field in lockstep with [project].version. +# Match the version key specifically so schema URLs (…/1.0.0/…) are not rewritten. version_files = [ - ".claude-plugin/plugin.json", - ".codex-plugin/plugin.json", + "plugin.json:\"version\":", + ".claude-plugin/plugin.json:\"version\":", + ".codex-plugin/plugin.json:\"version\":", ] [dependency-groups] diff --git a/tests/test_agent_plugins.py b/tests/test_agent_plugins.py new file mode 100644 index 00000000..4fe4c2e1 --- /dev/null +++ b/tests/test_agent_plugins.py @@ -0,0 +1,183 @@ +"""Agent Plugins v1.0.0 portable package contract. + +This repo ships as an Agent Plugins package (root ``plugin.json`` + ``mcp.json`` ++ ``skills/``) while retaining harness-specific manifests under +``.claude-plugin/`` and ``.codex-plugin/``. These tests pin the portable floor +and keep version fields in lockstep with ``[project].version``. +""" + +from __future__ import annotations + +import json +import re +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" +MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json" + +# Closed portable manifest fields (Agent Plugins §5.2). +PLUGIN_TOP_LEVEL = { + "$schema", + "name", + "version", + "description", + "author", + "homepage", + "repository", + "license", + "keywords", + "extensions", +} + +# Plugin name constraints (Agent Plugins §5.5). +PLUGIN_NAME_RE = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$") + + +def _load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _project_version() -> str: + data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + return data["project"]["version"] + + +def test_portable_plugin_manifest_is_agent_plugins_v1(): + manifest = _load_json(ROOT / "plugin.json") + + assert set(manifest) <= PLUGIN_TOP_LEVEL + assert manifest["$schema"] == PLUGIN_SCHEMA + assert isinstance(manifest["name"], str) + assert 1 <= len(manifest["name"]) <= 64 + assert PLUGIN_NAME_RE.fullmatch(manifest["name"]), manifest["name"] + assert manifest["name"] == "dataiku-headless" + assert isinstance(manifest.get("version"), str) and manifest["version"] + assert isinstance(manifest.get("description"), str) and manifest["description"] + assert isinstance(manifest.get("license"), str) and manifest["license"] + assert isinstance(manifest.get("keywords"), list) + assert all(isinstance(k, str) for k in manifest["keywords"]) + + author = manifest.get("author") + if author is not None: + assert isinstance(author, dict) + assert set(author) <= {"name", "email", "url"} + assert all(isinstance(v, str) for v in author.values()) + + +def test_portable_mcp_config_is_agent_plugins_v1_stdio(): + config = _load_json(ROOT / "mcp.json") + + assert set(config) == {"$schema", "mcpServers"} + assert config["$schema"] == MCP_SCHEMA + assert isinstance(config["mcpServers"], dict) + assert "dataiku" in config["mcpServers"] + + server = config["mcpServers"]["dataiku"] + assert set(server) <= {"type", "command", "args", "env", "cwd"} + assert server["type"] == "stdio" + assert server["command"] == "sh" + assert isinstance(server.get("args"), list) + assert server["args"] == ["${PLUGIN_ROOT}/bin/launcher.sh"] + + env = server.get("env", {}) + assert isinstance(env, dict) + assert all(isinstance(k, str) and isinstance(v, str) for k, v in env.items()) + # Reserved names are client-supplied only (Agent Plugins §9.2). + assert "PLUGIN_ROOT" not in env + assert "PLUGIN_DATA" not in env + assert env.get("UV_CACHE_DIR") == "${PLUGIN_DATA}/uv-cache" + + cwd = server.get("cwd") + if cwd is not None: + assert cwd.startswith(("./", "${PLUGIN_ROOT}", "${PLUGIN_DATA}")) + + +def test_plugin_and_mcp_schema_versions_match(): + plugin = _load_json(ROOT / "plugin.json") + mcp = _load_json(ROOT / "mcp.json") + plugin_version = plugin["$schema"].rsplit("/", 2)[1] + mcp_version = mcp["$schema"].rsplit("/", 2)[1] + assert plugin_version == mcp_version == "1.0.0" + + +def test_skill_is_discovered_as_immediate_child_of_skills(): + skill_md = ROOT / "skills" / "dataiku-headless" / "SKILL.md" + assert skill_md.is_file() + # Agent Plugins discovers only immediate children of skills/; nested + # SKILL.md under references/ must not appear as sibling skills. + skill_dirs = [ + p for p in (ROOT / "skills").iterdir() if p.is_dir() and (p / "SKILL.md").is_file() + ] + assert [p.name for p in skill_dirs] == ["dataiku-headless"] + + +def test_plugin_versions_match_project_version(): + expected = _project_version() + portable = _load_json(ROOT / "plugin.json")["version"] + claude = _load_json(ROOT / ".claude-plugin" / "plugin.json")["version"] + codex = _load_json(ROOT / ".codex-plugin" / "plugin.json")["version"] + assert portable == claude == codex == expected + + +def test_launcher_prefers_agent_plugins_data_dir(tmp_path): + """PLUGIN_DATA / PLUGIN_ROOT win over Claude-specific and local defaults.""" + import os + import subprocess + + launcher = (ROOT / "bin" / "launcher.sh").read_text(encoding="utf-8") + # Extract the real assignment lines so this test cannot drift from launcher.sh. + match = re.search( + r"^PLUGIN_ROOT=\$\{PLUGIN_ROOT:-.*\nDATA_DIR=\$\{PLUGIN_DATA:-.*$", + launcher, + re.MULTILINE, + ) + assert match, "launcher.sh lost PLUGIN_ROOT/DATA_DIR assignment order" + probe = tmp_path / "probe.sh" + probe.write_text( + "set -eu\n" + 'HERE=$(CDPATH=\'\' cd -- "$(dirname -- "$0")" && pwd)\n' + f"{match.group(0)}\n" + 'printf \'%s\\n\' "$PLUGIN_ROOT"\n' + 'printf \'%s\\n\' "$DATA_DIR"\n', + encoding="utf-8", + ) + probe.chmod(0o755) + + env = os.environ.copy() + for key in ( + "PLUGIN_ROOT", + "PLUGIN_DATA", + "CLAUDE_PLUGIN_ROOT", + "CLAUDE_PLUGIN_DATA", + ): + env.pop(key, None) + + agent_root = tmp_path / "agent-root" + agent_data = tmp_path / "agent-data" + claude_root = tmp_path / "claude-root" + claude_data = tmp_path / "claude-data" + for path in (agent_root, agent_data, claude_root, claude_data): + path.mkdir() + + env.update( + { + "PLUGIN_ROOT": str(agent_root), + "PLUGIN_DATA": str(agent_data), + "CLAUDE_PLUGIN_ROOT": str(claude_root), + "CLAUDE_PLUGIN_DATA": str(claude_data), + } + ) + out = subprocess.check_output(["sh", str(probe)], env=env, text=True) + root, data = out.splitlines() + assert root == str(agent_root) + assert data == str(agent_data) + + env.pop("PLUGIN_ROOT") + env.pop("PLUGIN_DATA") + out = subprocess.check_output(["sh", str(probe)], env=env, text=True) + root, data = out.splitlines() + assert root == str(claude_root) + assert data == str(claude_data) From 8326d4316fc84d2e048a2956f9a93eb2d032706d Mon Sep 17 00:00:00 2001 From: crmapj Date: Thu, 6 Aug 2026 18:28:35 +0000 Subject: [PATCH 06/12] fix(plugins): harden Agent Plugins packaging after review Address Sol/xhigh adversarial findings: drop tomllib so 3.10 CI can collect tests, verify manifests before pushing release tags, lock schema URL rewrites, and cover version-selector + local-fallback cases. --- .github/workflows/bump.yml | 29 ++++++++++++++- AGENTS.md | 3 +- RELEASE.md | 9 ++--- tests/test_agent_plugins.py | 70 ++++++++++++++++++++++++++++++++++--- 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml index 18d6fdab..583e2236 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -84,10 +84,14 @@ jobs: - id: before name: Record current tag run: echo "tag=$(git describe --tags --abbrev=0 2>/dev/null || true)" >> "$GITHUB_OUTPUT" + # Keep the bump local until manifests are verified. commitizen-action's + # push defaults to true, which would publish a release tag before we can + # fail on a silent version_files miss. - name: Bump version, changelog and tag uses: commitizen-tools/commitizen-action@338bbd841b75aaee6bf5340e1fa12f6ab58ff9ff # 0.27.1 with: github_token: ${{ secrets.GITHUB_TOKEN }} + push: false # 3 = no commits found, 21 = nothing to bump: treat both as a no-op # rather than a failed run. no_raise: "3,21" @@ -109,7 +113,7 @@ jobs: # but a version_files entry whose version string no longer matches is # skipped *silently* — the run stays green while the plugin manifests keep # advertising the old version, which is exactly the field harnesses use to - # decide whether users get an update. Fail loudly instead. + # decide whether users get an update. Fail loudly instead, before any push. - name: Verify plugin manifests carry the bumped version id: version run: | @@ -140,8 +144,31 @@ jobs: exit 1 fi + # Guard the Agent Plugins schema identifiers: a botched version rewrite + # must not rewrite the 1.0.0 schema path segment. + for schema_file in plugin.json mcp.json; do + schema=$(jq -r '."$schema"' "${schema_file}") + case "${schema}" in + https://agent-plugins.org/schemas/1.0.0/*) ;; + *) + echo "::error::${schema_file} \$schema drifted to ${schema}." + exit 1 + ;; + esac + done + echo "version=${project}" >> "${GITHUB_OUTPUT}" + # Publish the local bump commit + vX.Y.Z tag only after verification. + - name: Push bump commit and version tag + if: steps.after.outputs.tag != '' + env: + TAG: ${{ steps.after.outputs.tag }} + run: | + set -euo pipefail + git push origin "HEAD:${{ github.ref_name }}" + git push origin "refs/tags/${TAG}" + # Plugin release tag, in the form `claude plugin tag` produces. Separate # from commitizen's vX.Y.Z: it marks the commit a harness resolves a plugin # install to. Derived from the tag commitizen actually created, so the two diff --git a/AGENTS.md b/AGENTS.md index 8abd1ca5..ad381db6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ Use this file when changing this repository. It is not an operating guide for us - Tool registration imports: `dataiku_mcp/__init__.py`. - Project dependencies, Python support, version, and CLI entry points: `pyproject.toml`. - Standalone server dependency pins and Python floor: the PEP 723 block in `bin/run_mcp.py`. -- Plugin launch configuration: `.mcp.json`, `.claude-plugin/plugin.json`, and `.codex-plugin/plugin.json`. +- Portable Agent Plugins package: root `plugin.json` + `mcp.json` (skills under `skills/`). Harness-specific launch config: `.mcp.json`, `.claude-plugin/plugin.json`, and `.codex-plugin/plugin.json`. - User-facing installation and architecture overview: `README.md`. - Release behavior: `RELEASE.md` and `.github/workflows/bump.yml`. - CI behavior: `.github/workflows/ci.yml` and `.github/workflows/pr-title.yml`. @@ -77,6 +77,7 @@ Useful focused checks include: ```bash uv run pytest tests/test_tool_surface.py uv run pytest tests/test_pep723_launcher.py +uv run pytest tests/test_agent_plugins.py uv run pytest tests/test_cobuild.py ``` diff --git a/RELEASE.md b/RELEASE.md index 41058c61..ab37b832 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -21,10 +21,11 @@ The version number still matters even without an index: Commitizen keeps it in lockstep across `pyproject.toml` and the plugin manifests (portable Agent Plugins `plugin.json`, plus the Claude Code and Codex compatibility manifests), and the manifest version is how a harness notices there's a newer plugin to install. -`bump.yml` verifies that lockstep held before it tags anything — a -`version_files` entry whose version string stops matching is skipped *silently* -by Commitizen, which would otherwise ship a release whose manifests still -advertise the old version. +`bump.yml` bumps locally first (`push: false`), verifies that lockstep held +across all manifests (and that Agent Plugins `$schema` URLs were not rewritten), +and only then pushes the bump commit and tags — a `version_files` entry whose +version string stops matching is skipped *silently* by Commitizen, which would +otherwise ship a release whose manifests still advertise the old version. --- diff --git a/tests/test_agent_plugins.py b/tests/test_agent_plugins.py index 4fe4c2e1..98e8b63f 100644 --- a/tests/test_agent_plugins.py +++ b/tests/test_agent_plugins.py @@ -8,9 +8,9 @@ from __future__ import annotations +import importlib.metadata import json import re -import tomllib from pathlib import Path ROOT = Path(__file__).resolve().parent.parent @@ -35,15 +35,19 @@ # Plugin name constraints (Agent Plugins §5.5). PLUGIN_NAME_RE = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$") +# cwd forms allowed by Agent Plugins §7.2.1 (stdio). +_CWD_RE = re.compile( + r"^(?:\./(?!\.\.)|\$\{PLUGIN_ROOT\}(?:/|$)|\$\{PLUGIN_DATA\}(?:/|$))" +) + def _load_json(path: Path) -> dict: return json.loads(path.read_text(encoding="utf-8")) def _project_version() -> str: - data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) - return data["project"]["version"] - + # Prefer the installed distribution so this works on Python 3.10 (no tomllib). + return importlib.metadata.version("dataiku-headless") def test_portable_plugin_manifest_is_agent_plugins_v1(): manifest = _load_json(ROOT / "plugin.json") @@ -92,7 +96,8 @@ def test_portable_mcp_config_is_agent_plugins_v1_stdio(): cwd = server.get("cwd") if cwd is not None: - assert cwd.startswith(("./", "${PLUGIN_ROOT}", "${PLUGIN_DATA}")) + assert _CWD_RE.match(cwd), cwd + assert ".." not in cwd def test_plugin_and_mcp_schema_versions_match(): @@ -113,6 +118,24 @@ def test_skill_is_discovered_as_immediate_child_of_skills(): ] assert [p.name for p in skill_dirs] == ["dataiku-headless"] + frontmatter = skill_md.read_text(encoding="utf-8").split("---", 2) + assert len(frontmatter) >= 3, "SKILL.md missing YAML frontmatter" + assert re.search(r"(?m)^name:\s*dataiku-headless\s*$", frontmatter[1]) + assert re.search(r"(?m)^description:\s*\S", frontmatter[1]) + + +def test_mcp_launcher_path_exists_in_package(): + """Portable mcp.json must point at a real package path after expansion.""" + config = _load_json(ROOT / "mcp.json") + server = config["mcpServers"]["dataiku"] + for arg in server.get("args", []): + # Expand only the placeholders this package uses. + expanded = arg.replace("${PLUGIN_ROOT}", str(ROOT)).replace( + "${PLUGIN_DATA}", str(ROOT / ".deps") + ) + if expanded.endswith("launcher.sh"): + assert Path(expanded).is_file(), expanded + def test_plugin_versions_match_project_version(): expected = _project_version() @@ -122,6 +145,29 @@ def test_plugin_versions_match_project_version(): assert portable == claude == codex == expected +def test_commitizen_version_selector_preserves_schema_urls(): + """Simulate commitizen's path:pattern rewrite so schema 1.0.0 is not clobbered.""" + # Mirrors commitizen.bump.update_version_in_files: replace only on lines that + # match the configured regex (here the version key). + pattern = re.compile(r'"version":') + text = (ROOT / "plugin.json").read_text(encoding="utf-8") + current = _project_version() + # Force a synthetic package version that collides with the schema segment. + synthetic = text.replace(f'"version": "{current}"', '"version": "1.0.0"', 1) + assert '"version": "1.0.0"' in synthetic + assert PLUGIN_SCHEMA in synthetic + + rewritten = [] + for line in synthetic.splitlines(keepends=True): + if pattern.search(line): + rewritten.append(line.replace("1.0.0", "1.0.1")) + else: + rewritten.append(line) + result = "".join(rewritten) + assert '"version": "1.0.1"' in result + assert PLUGIN_SCHEMA in result # schema URL must keep 1.0.0 + + def test_launcher_prefers_agent_plugins_data_dir(tmp_path): """PLUGIN_DATA / PLUGIN_ROOT win over Claude-specific and local defaults.""" import os @@ -181,3 +227,17 @@ def test_launcher_prefers_agent_plugins_data_dir(tmp_path): root, data = out.splitlines() assert root == str(claude_root) assert data == str(claude_data) + + # Local checkout fallback when no harness vars are set. + env.pop("CLAUDE_PLUGIN_ROOT") + env.pop("CLAUDE_PLUGIN_DATA") + # Put the probe under a fake bin/ so HERE/.. resolves like launcher.sh. + fake_bin = tmp_path / "checkout" / "bin" + fake_bin.mkdir(parents=True) + local_probe = fake_bin / "probe.sh" + local_probe.write_text(probe.read_text(encoding="utf-8"), encoding="utf-8") + local_probe.chmod(0o755) + out = subprocess.check_output(["sh", str(local_probe)], env=env, text=True) + root, data = out.splitlines() + assert root == str(tmp_path / "checkout") + assert data == str(tmp_path / "checkout" / ".deps") From 6607baf102bc94a9bf96da350c5d5f84921145cd Mon Sep 17 00:00:00 2001 From: crmapj Date: Thu, 6 Aug 2026 19:08:54 +0000 Subject: [PATCH 07/12] style: ruff-format agent plugins tests CI pre-commit failed because ruff-format rewrote tests/test_agent_plugins.py. --- tests/test_agent_plugins.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_agent_plugins.py b/tests/test_agent_plugins.py index 98e8b63f..350b2720 100644 --- a/tests/test_agent_plugins.py +++ b/tests/test_agent_plugins.py @@ -49,6 +49,7 @@ def _project_version() -> str: # Prefer the installed distribution so this works on Python 3.10 (no tomllib). return importlib.metadata.version("dataiku-headless") + def test_portable_plugin_manifest_is_agent_plugins_v1(): manifest = _load_json(ROOT / "plugin.json") @@ -114,7 +115,9 @@ def test_skill_is_discovered_as_immediate_child_of_skills(): # Agent Plugins discovers only immediate children of skills/; nested # SKILL.md under references/ must not appear as sibling skills. skill_dirs = [ - p for p in (ROOT / "skills").iterdir() if p.is_dir() and (p / "SKILL.md").is_file() + p + for p in (ROOT / "skills").iterdir() + if p.is_dir() and (p / "SKILL.md").is_file() ] assert [p.name for p in skill_dirs] == ["dataiku-headless"] @@ -186,8 +189,8 @@ def test_launcher_prefers_agent_plugins_data_dir(tmp_path): "set -eu\n" 'HERE=$(CDPATH=\'\' cd -- "$(dirname -- "$0")" && pwd)\n' f"{match.group(0)}\n" - 'printf \'%s\\n\' "$PLUGIN_ROOT"\n' - 'printf \'%s\\n\' "$DATA_DIR"\n', + "printf '%s\\n' \"$PLUGIN_ROOT\"\n" + "printf '%s\\n' \"$DATA_DIR\"\n", encoding="utf-8", ) probe.chmod(0o755) From 451ae1a0df17b5ac7d5e332d55d9cc953d02997c Mon Sep 17 00:00:00 2001 From: crmapj Date: Thu, 6 Aug 2026 19:11:59 +0000 Subject: [PATCH 08/12] ci: re-trigger checks after ruff-format fix From 3e03ea8742fd0789bb0b2b2f33f0e0657fcbb3bd Mon Sep 17 00:00:00 2001 From: pmasiphelps Date: Wed, 26 Aug 2026 08:32:26 -0400 Subject: [PATCH 09/12] fix(plugins): keep portable MCP on direct uv launch --- tests/test_agent_plugins.py | 105 ++++-------------------------------- 1 file changed, 11 insertions(+), 94 deletions(-) diff --git a/tests/test_agent_plugins.py b/tests/test_agent_plugins.py index 350b2720..01fde450 100644 --- a/tests/test_agent_plugins.py +++ b/tests/test_agent_plugins.py @@ -83,17 +83,15 @@ def test_portable_mcp_config_is_agent_plugins_v1_stdio(): server = config["mcpServers"]["dataiku"] assert set(server) <= {"type", "command", "args", "env", "cwd"} assert server["type"] == "stdio" - assert server["command"] == "sh" + assert server["command"] == "uv" assert isinstance(server.get("args"), list) - assert server["args"] == ["${PLUGIN_ROOT}/bin/launcher.sh"] - - env = server.get("env", {}) - assert isinstance(env, dict) - assert all(isinstance(k, str) and isinstance(v, str) for k, v in env.items()) - # Reserved names are client-supplied only (Agent Plugins §9.2). - assert "PLUGIN_ROOT" not in env - assert "PLUGIN_DATA" not in env - assert env.get("UV_CACHE_DIR") == "${PLUGIN_DATA}/uv-cache" + assert server["args"] == [ + "run", + "--quiet", + "--locked", + "--script", + "./bin/run_mcp.py", + ] cwd = server.get("cwd") if cwd is not None: @@ -127,17 +125,11 @@ def test_skill_is_discovered_as_immediate_child_of_skills(): assert re.search(r"(?m)^description:\s*\S", frontmatter[1]) -def test_mcp_launcher_path_exists_in_package(): - """Portable mcp.json must point at a real package path after expansion.""" +def test_mcp_script_path_exists_in_package(): + """Portable mcp.json must point at the supported script entry point.""" config = _load_json(ROOT / "mcp.json") server = config["mcpServers"]["dataiku"] - for arg in server.get("args", []): - # Expand only the placeholders this package uses. - expanded = arg.replace("${PLUGIN_ROOT}", str(ROOT)).replace( - "${PLUGIN_DATA}", str(ROOT / ".deps") - ) - if expanded.endswith("launcher.sh"): - assert Path(expanded).is_file(), expanded + assert (ROOT / server["args"][-1]).is_file() def test_plugin_versions_match_project_version(): @@ -169,78 +161,3 @@ def test_commitizen_version_selector_preserves_schema_urls(): result = "".join(rewritten) assert '"version": "1.0.1"' in result assert PLUGIN_SCHEMA in result # schema URL must keep 1.0.0 - - -def test_launcher_prefers_agent_plugins_data_dir(tmp_path): - """PLUGIN_DATA / PLUGIN_ROOT win over Claude-specific and local defaults.""" - import os - import subprocess - - launcher = (ROOT / "bin" / "launcher.sh").read_text(encoding="utf-8") - # Extract the real assignment lines so this test cannot drift from launcher.sh. - match = re.search( - r"^PLUGIN_ROOT=\$\{PLUGIN_ROOT:-.*\nDATA_DIR=\$\{PLUGIN_DATA:-.*$", - launcher, - re.MULTILINE, - ) - assert match, "launcher.sh lost PLUGIN_ROOT/DATA_DIR assignment order" - probe = tmp_path / "probe.sh" - probe.write_text( - "set -eu\n" - 'HERE=$(CDPATH=\'\' cd -- "$(dirname -- "$0")" && pwd)\n' - f"{match.group(0)}\n" - "printf '%s\\n' \"$PLUGIN_ROOT\"\n" - "printf '%s\\n' \"$DATA_DIR\"\n", - encoding="utf-8", - ) - probe.chmod(0o755) - - env = os.environ.copy() - for key in ( - "PLUGIN_ROOT", - "PLUGIN_DATA", - "CLAUDE_PLUGIN_ROOT", - "CLAUDE_PLUGIN_DATA", - ): - env.pop(key, None) - - agent_root = tmp_path / "agent-root" - agent_data = tmp_path / "agent-data" - claude_root = tmp_path / "claude-root" - claude_data = tmp_path / "claude-data" - for path in (agent_root, agent_data, claude_root, claude_data): - path.mkdir() - - env.update( - { - "PLUGIN_ROOT": str(agent_root), - "PLUGIN_DATA": str(agent_data), - "CLAUDE_PLUGIN_ROOT": str(claude_root), - "CLAUDE_PLUGIN_DATA": str(claude_data), - } - ) - out = subprocess.check_output(["sh", str(probe)], env=env, text=True) - root, data = out.splitlines() - assert root == str(agent_root) - assert data == str(agent_data) - - env.pop("PLUGIN_ROOT") - env.pop("PLUGIN_DATA") - out = subprocess.check_output(["sh", str(probe)], env=env, text=True) - root, data = out.splitlines() - assert root == str(claude_root) - assert data == str(claude_data) - - # Local checkout fallback when no harness vars are set. - env.pop("CLAUDE_PLUGIN_ROOT") - env.pop("CLAUDE_PLUGIN_DATA") - # Put the probe under a fake bin/ so HERE/.. resolves like launcher.sh. - fake_bin = tmp_path / "checkout" / "bin" - fake_bin.mkdir(parents=True) - local_probe = fake_bin / "probe.sh" - local_probe.write_text(probe.read_text(encoding="utf-8"), encoding="utf-8") - local_probe.chmod(0o755) - out = subprocess.check_output(["sh", str(local_probe)], env=env, text=True) - root, data = out.splitlines() - assert root == str(tmp_path / "checkout") - assert data == str(tmp_path / "checkout" / ".deps") From 799b91b4b79c51f8915d7dc94c4b01502074186d Mon Sep 17 00:00:00 2001 From: pmasiphelps Date: Wed, 9 Sep 2026 14:54:47 -0400 Subject: [PATCH 10/12] fix(plugins): support Claude-hosted uploads --- .claude-plugin/plugin.json | 2 +- .github/workflows/bump.yml | 10 +++++----- .github/workflows/ci.yml | 2 +- .mcp.json | 2 +- AGENTS.md | 8 ++++---- CODING_STANDARDS_AND_STRUCTURE.md | 8 ++++---- README.md | 6 +++--- RELEASE.md | 4 ++-- {bin => runtime}/launcher.sh | 2 +- {bin => runtime}/run_mcp.py | 8 ++++---- {bin => runtime}/run_mcp.py.lock | 0 skills/dataiku-headless-setup/SKILL.md | 6 +++--- tests/test_pep723_launcher.py | 20 ++++++++++---------- 13 files changed, 39 insertions(+), 39 deletions(-) rename {bin => runtime}/launcher.sh (98%) rename {bin => runtime}/run_mcp.py (87%) rename {bin => runtime}/run_mcp.py.lock (100%) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f237de18..dcb618af 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -15,7 +15,7 @@ "dataiku": { "type": "stdio", "command": "uv", - "args": ["run", "--quiet", "--locked", "--script", "${CLAUDE_PLUGIN_ROOT}/bin/run_mcp.py"] + "args": ["run", "--quiet", "--locked", "--script", "${CLAUDE_PLUGIN_ROOT}/runtime/run_mcp.py"] } } } diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml index f5ef138b..a9a1f3d6 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -74,11 +74,11 @@ jobs: - name: Verify PEP 723 script lockfile is current run: | set -euo pipefail - uv lock --script bin/run_mcp.py --check - uv lock --script bin/run_mcp.py --upgrade - if ! git diff --quiet -- bin/run_mcp.py.lock; then - echo "::error::bin/run_mcp.py.lock is stale. Regenerate, test, and commit it before releasing." - git --no-pager diff -- bin/run_mcp.py.lock + uv lock --script runtime/run_mcp.py --check + uv lock --script runtime/run_mcp.py --upgrade + if ! git diff --quiet -- runtime/run_mcp.py.lock; then + echo "::error::runtime/run_mcp.py.lock is stale. Regenerate, test, and commit it before releasing." + git --no-pager diff -- runtime/run_mcp.py.lock exit 1 fi - id: before diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d003a88d..172e08b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - name: Install locked dependencies run: uv sync --locked - name: Verify PEP 723 script lockfile - run: uv lock --script bin/run_mcp.py --check + run: uv lock --script runtime/run_mcp.py --check # Runs every hook in .pre-commit-config.yaml (ruff lint, file hygiene, # uv-lock) against all files. commit-msg hooks are skipped by --all-files. - name: Run pre-commit diff --git a/.mcp.json b/.mcp.json index 61e55fe5..b362f311 100644 --- a/.mcp.json +++ b/.mcp.json @@ -7,7 +7,7 @@ "--quiet", "--locked", "--script", - "./bin/run_mcp.py" + "./runtime/run_mcp.py" ], "cwd": ".", "env_vars": [ diff --git a/AGENTS.md b/AGENTS.md index b094c0cb..9ec98aa4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ Use this file when changing this repository. It is not an operating guide for us - `dataiku_mcp/tools/cobuild.py` owns retained Cobuild conversations. Cobuild is the default path for constructing or modifying project-level flow and analytic assets. - Cobuild owns construction and modification of project-level flow and analytic assets. Direct writes are allowed only for the fixed exception categories defined by the **Cobuild Write-Routing Convention** in `CODING_STANDARDS_AND_STRUCTURE.md`; do not infer permission for a new direct write from existing implementation. - `skills/dataiku-headless/SKILL.md` is the operator-facing router. Its `references/` directory owns object-specific inspection, mutation, and verification workflows. -- `bin/run_mcp.py` is the entry point used by manifests through `uv run --quiet`. It owns the PEP 723 runtime metadata. Stdout is reserved for MCP JSON-RPC; diagnostics belong on stderr. `bin/launcher.sh` is inactive legacy fallback code retained for possible future use. +- `runtime/run_mcp.py` is the entry point used by manifests through `uv run --quiet`. It owns the PEP 723 runtime metadata. Stdout is reserved for MCP JSON-RPC; diagnostics belong on stderr. `runtime/launcher.sh` is inactive legacy fallback code retained for possible future use. ## Sources of truth @@ -24,7 +24,7 @@ Use this file when changing this repository. It is not an operating guide for us - User-facing capability boundary and tool inventory: `docs/capabilities.md`, enforced by `tests/test_capabilities_doc.py`. - Tool registration imports: `dataiku_mcp/__init__.py`. - Project dependencies, Python support, version, and CLI entry points: `pyproject.toml`. -- Standalone server dependency pins and Python floor: the PEP 723 block in `bin/run_mcp.py`. +- Standalone server dependency pins and Python floor: the PEP 723 block in `runtime/run_mcp.py`. - Plugin launch configuration: `.mcp.json`, `.claude-plugin/plugin.json`, and `.codex-plugin/plugin.json`. - User-facing installation and architecture overview: `README.md`. - Release behavior: `RELEASE.md` and `.github/workflows/bump.yml`. @@ -49,8 +49,8 @@ Do not copy volatile inventories, versions, or command details into this file wh - Preserve Cobuild conversation ownership by instance and project, one active turn per conversation, exact turn IDs for answers, and recoverable polling after timeouts or cancellation. - Keep general SDK work and long-running Cobuild calls on their separate executors. -- Keep `bin/launcher.sh` POSIX `/bin/sh` compatible and silent on stdout if modifying its retained legacy fallback behavior. -- When runtime dependencies or the Python floor change, update both `pyproject.toml` and the PEP 723 block in `bin/run_mcp.py`; `tests/test_pep723_launcher.py` enforces their relationship. +- Keep `runtime/launcher.sh` POSIX `/bin/sh` compatible and silent on stdout if modifying its retained legacy fallback behavior. +- When runtime dependencies or the Python floor change, update both `pyproject.toml` and the PEP 723 block in `runtime/run_mcp.py`; `tests/test_pep723_launcher.py` enforces their relationship. - When manifest behavior changes, inspect every manifest rather than assuming their schemas or path interpolation rules are identical. - When changing package contents or entry points, build the distributions and smoke-test the wheel as CI does. diff --git a/CODING_STANDARDS_AND_STRUCTURE.md b/CODING_STANDARDS_AND_STRUCTURE.md index 7a95d8bc..38dc7c70 100644 --- a/CODING_STANDARDS_AND_STRUCTURE.md +++ b/CODING_STANDARDS_AND_STRUCTURE.md @@ -97,14 +97,14 @@ PYTHONPYCACHEPREFIX=/tmp/pycache uv run python -m py_compile $(find dataiku_mcp Run the MCP server locally to verify end-to-end: ```bash -uv run --quiet --locked --script ./bin/run_mcp.py # exactly what every manifest runs +uv run --quiet --locked --script ./runtime/run_mcp.py # exactly what every manifest runs ``` -`uv` 0.12.0 or later is a runtime prerequisite for the plugin. **`bin/run_mcp.py`** is the server entry point: its [PEP 723](https://peps.python.org/pep-0723/) inline metadata declares pinned dependencies and `requires-python`, so uv creates an isolated cached environment without a project install. `dataiku_mcp` is imported from the working tree, so source edits take effect immediately, while local edits to dependencies do not. +`uv` 0.12.0 or later is a runtime prerequisite for the plugin. **`runtime/run_mcp.py`** is the server entry point: its [PEP 723](https://peps.python.org/pep-0723/) inline metadata declares pinned dependencies and `requires-python`, so uv creates an isolated cached environment without a project install. `dataiku_mcp` is imported from the working tree, so source edits take effect immediately, while local edits to dependencies do not. -**`bin/launcher.sh`** is inactive legacy code retained for possible future fallback use. No manifest invokes it; do not re-enable it without explicitly reviewing the platform behavior and updating all manifests. +**`runtime/launcher.sh`** is inactive legacy code retained for possible future fallback use. No manifest invokes it; do not re-enable it without explicitly reviewing the platform behavior and updating all manifests. -The inline metadata and its adjacent `bin/run_mcp.py.lock` resolve independently of the project `uv.lock`. The `==` pins are the direct dependency constraints for plugin launches; the script lock records the complete direct and transitive resolution. Bump direct pins deliberately, then regenerate and commit the script lock with `uv lock --script bin/run_mcp.py`. Every launcher uses `--locked`, so a stale or absent script lock fails before server startup rather than resolving on a user's machine. +The inline metadata and its adjacent `runtime/run_mcp.py.lock` resolve independently of the project `uv.lock`. The `==` pins are the direct dependency constraints for plugin launches; the script lock records the complete direct and transitive resolution. Bump direct pins deliberately, then regenerate and commit the script lock with `uv lock --script runtime/run_mcp.py`. Every launcher uses `--locked`, so a stale or absent script lock fails before server startup rather than resolving on a user's machine. The inline dependency list duplicates `[project].dependencies`; `tests/test_pep723_launcher.py` fails if the two drift apart. diff --git a/README.md b/README.md index 35c47127..047b6b5c 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Add the following to your `.mcp.json` from a checkout of this repository: "mcp": { "dataiku": { "type": "local", - "command": ["uv", "run", "--quiet", "--locked", "--script", "./bin/run_mcp.py"], + "command": ["uv", "run", "--quiet", "--locked", "--script", "./runtime/run_mcp.py"], "enabled": true } } @@ -193,7 +193,7 @@ Auth resolution order: Every install path above has your harness launch the server itself. Run it standalone only if you're testing it directly — from a clone of this repo: ```bash -uv run --quiet --locked --script ./bin/run_mcp.py # same command the plugin manifests use +uv run --quiet --locked --script ./runtime/run_mcp.py # same command the plugin manifests use ``` ## Project Structure @@ -252,7 +252,7 @@ uv run --quiet --locked --script ./bin/run_mcp.py # same command the plugin ma │ ├── agents.md # Agent and agent-tool inspection │ ├── ... # Additional references for dashboards, insights, scenarios, wikis, migrations, and more │ └── recipes/ # Nested recipe-family and shared recipe references -├── bin/ +├── runtime/ │ ├── launcher.sh # Inactive legacy fallback retained for possible future use │ ├── run_mcp.py # Server entry point: PEP 723 script pinning the runtime deps inline │ └── run_mcp.py.lock # Committed, full dependency resolution for the entry point diff --git a/RELEASE.md b/RELEASE.md index 50e0ce2c..e5e1e674 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -48,9 +48,9 @@ PR and is the gate before anything merges to `main`. Before Commitizen can create a version, `bump.yml` also verifies the PEP 723 script lockfile is valid and refreshes it in the disposable runner. If the latest allowed transitive dependency resolution differs from -`bin/run_mcp.py.lock`, the workflow fails before changing the version or +`runtime/run_mcp.py.lock`, the workflow fails before changing the version or creating tags. Regenerate the lock, run the normal checks, and commit it in a -PR; direct dependencies remain deliberately pinned in `bin/run_mcp.py`. +PR; direct dependencies remain deliberately pinned in `runtime/run_mcp.py`. ### Workflow files diff --git a/bin/launcher.sh b/runtime/launcher.sh similarity index 98% rename from bin/launcher.sh rename to runtime/launcher.sh index cea1b3a9..532dc3a6 100644 --- a/bin/launcher.sh +++ b/runtime/launcher.sh @@ -14,7 +14,7 @@ # limitations under the License. # LEGACY / INACTIVE: Plugin manifests now invoke ``uv run --quiet -# bin/run_mcp.py`` directly so that native Windows hosts are supported. This +# runtime/run_mcp.py`` directly so that native Windows hosts are supported. This # launcher is retained for possible future fallback use, but no supported # installation path invokes it. Do not treat it as the active server entry # point without explicitly restoring manifest support and reviewing its diff --git a/bin/run_mcp.py b/runtime/run_mcp.py similarity index 87% rename from bin/run_mcp.py rename to runtime/run_mcp.py index e94ea950..362e601f 100755 --- a/bin/run_mcp.py +++ b/runtime/run_mcp.py @@ -28,16 +28,16 @@ fly, so a harness with uv 0.12.0 or later can start the server without a project install: - uv run --quiet --locked --script bin/run_mcp.py + uv run --quiet --locked --script runtime/run_mcp.py -The plugin manifests invoke this script directly through uv. ``bin/launcher.sh`` +The plugin manifests invoke this script directly through uv. ``runtime/launcher.sh`` is retained as inactive legacy code for a possible future fallback path. -uv installs the committed ``bin/run_mcp.py.lock`` resolution into a cached, +uv installs the committed ``runtime/run_mcp.py.lock`` resolution into a cached, isolated environment on the first launch and reuses it afterwards. ``--locked`` prevents a launch from resolving or changing that lock. After deliberately changing the inline metadata, regenerate the lock with -``uv lock --script bin/run_mcp.py`` and commit it. The ``==`` pins above remain +``uv lock --script runtime/run_mcp.py`` and commit it. The ``==`` pins above remain the direct dependency constraints; the lock also records their transitive dependencies. diff --git a/bin/run_mcp.py.lock b/runtime/run_mcp.py.lock similarity index 100% rename from bin/run_mcp.py.lock rename to runtime/run_mcp.py.lock diff --git a/skills/dataiku-headless-setup/SKILL.md b/skills/dataiku-headless-setup/SKILL.md index 3489c358..c23bdd56 100644 --- a/skills/dataiku-headless-setup/SKILL.md +++ b/skills/dataiku-headless-setup/SKILL.md @@ -15,9 +15,9 @@ Bring a new or broken plugin installation to a verified Dataiku connection. A re - Windows PowerShell: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"` 3. Obtain explicit approval, run only the selected installer, and verify with `uv --version`. Do not substitute a third-party package manager or edit shell startup files unless the user asks. 4. If the current agent process cannot see the newly installed executable, use the installer's reported location to confirm it exists, then ask the user to fully restart or reload the agent. Stop and resume setup in the new session; the already-running MCP process cannot repair its own launch environment. -5. Once uv is suitable, warm the runtime by running the server once with stdin closed. Locate the absolute path of this `SKILL.md`; the plugin root is its ancestor containing both `skills/` and `bin/` (this file is at `/skills/dataiku-headless-setup/SKILL.md`). Do not assume the current working directory is the plugin root. - - macOS or Linux: `uv run --quiet --locked --script "/bin/run_mcp.py" < /dev/null` - - Windows PowerShell: `$null | uv run --quiet --locked --script "\bin\run_mcp.py"` +5. Once uv is suitable, warm the runtime by running the server once with stdin closed. Locate the absolute path of this `SKILL.md`; the plugin root is its ancestor containing both `skills/` and `runtime/` (this file is at `/skills/dataiku-headless-setup/SKILL.md`). Do not assume the current working directory is the plugin root. + - macOS or Linux: `uv run --quiet --locked --script "/runtime/run_mcp.py" < /dev/null` + - Windows PowerShell: `$null | uv run --quiet --locked --script "\runtime\run_mcp.py"` A startup line on stderr followed by exit status 0 is expected. Do not substitute `uv sync --locked --script`: it caches downloads but leaves environment creation for the first server launch. 6. Check whether the Dataiku MCP tools are available. If they are not, reload the plugin or restart the agent once before diagnosing a Dataiku connection problem. diff --git a/tests/test_pep723_launcher.py b/tests/test_pep723_launcher.py index 921ce175..5513d082 100644 --- a/tests/test_pep723_launcher.py +++ b/tests/test_pep723_launcher.py @@ -14,7 +14,7 @@ """The PEP 723 server script must stay in lockstep with the project's metadata. -``bin/run_mcp.py`` declares its own dependencies inline so a harness can start +``runtime/run_mcp.py`` declares its own dependencies inline so a harness can start the server through ``uv run --quiet --locked --script`` instead of a pre-built environment. That duplicated dependency list silently rots when ``pyproject.toml`` changes, and the failure only surfaces at server startup on a user's machine — so pin it @@ -32,7 +32,7 @@ from packaging.requirements import Requirement -SCRIPT = Path(__file__).resolve().parent.parent / "bin" / "run_mcp.py" +SCRIPT = Path(__file__).resolve().parent.parent / "runtime" / "run_mcp.py" SCRIPT_LOCK = SCRIPT.with_suffix(".py.lock") @@ -43,7 +43,7 @@ def _inline_metadata() -> str: SCRIPT.read_text(encoding="utf-8"), re.DOTALL | re.MULTILINE, ) - assert block, "bin/run_mcp.py lost its PEP 723 inline metadata block" + assert block, "runtime/run_mcp.py lost its PEP 723 inline metadata block" return "\n".join( line.removeprefix("#").strip() for line in block.group(1).splitlines() ) @@ -51,7 +51,7 @@ def _inline_metadata() -> str: def _inline_requirements() -> dict: array = re.search(r"dependencies\s*=\s*\[(.*?)\]", _inline_metadata(), re.DOTALL) - assert array, "bin/run_mcp.py declares no inline dependencies" + assert array, "runtime/run_mcp.py declares no inline dependencies" parsed = [Requirement(spec) for spec in re.findall(r'"([^"]+)"', array.group(1))] return {req.name.lower().replace("_", "-"): req for req in parsed} @@ -66,7 +66,7 @@ def _project_requirements() -> dict: def test_inline_dependencies_cover_the_same_packages(): assert set(_inline_requirements()) == set(_project_requirements()), ( - "bin/run_mcp.py inline dependencies drifted from [project].dependencies " + "runtime/run_mcp.py inline dependencies drifted from [project].dependencies " "in pyproject.toml" ) @@ -75,7 +75,7 @@ def test_inline_dependencies_are_pinned(): for name, req in _inline_requirements().items(): specifiers = list(req.specifier) assert len(specifiers) == 1 and specifiers[0].operator == "==", ( - f"{name} must be pinned to an exact version in bin/run_mcp.py: the " + f"{name} must be pinned to an exact version in runtime/run_mcp.py: the " "script's direct dependency constraints must be explicit (got " f"{str(req.specifier) or 'no specifier'})" ) @@ -83,8 +83,8 @@ def test_inline_dependencies_are_pinned(): def test_script_lockfile_exists(): assert SCRIPT_LOCK.is_file(), ( - "bin/run_mcp.py.lock is required because launchers use uv --locked; " - "regenerate it with `uv lock --script bin/run_mcp.py`" + "runtime/run_mcp.py.lock is required because launchers use uv --locked; " + "regenerate it with `uv lock --script runtime/run_mcp.py`" ) @@ -93,14 +93,14 @@ def test_inline_pins_satisfy_project_constraints(): for name, req in _inline_requirements().items(): pinned = str(req.specifier).removeprefix("==") assert project[name].specifier.contains(pinned, prereleases=True), ( - f"bin/run_mcp.py pins {name}=={pinned}, which violates " + f"runtime/run_mcp.py pins {name}=={pinned}, which violates " f"'{project[name]}' in pyproject.toml" ) def test_inline_requires_python_matches_project(): inline = re.search(r'requires-python\s*=\s*"([^"]+)"', _inline_metadata()) - assert inline, "bin/run_mcp.py declares no inline requires-python" + assert inline, "runtime/run_mcp.py declares no inline requires-python" expected = importlib.metadata.metadata("dataiku-headless")["Requires-Python"] assert inline.group(1) == expected From 9a7fe8f47e082021ccfd7837e06627d6f9a58a7e Mon Sep 17 00:00:00 2001 From: pmasiphelps Date: Thu, 10 Sep 2026 09:29:56 -0400 Subject: [PATCH 11/12] fix(plugins): update portable runtime launch --- README.md | 4 ++-- mcp.json | 8 +++++++- plugin.json | 2 +- pyproject.toml | 6 ++++++ tests/test_agent_plugins.py | 10 +++++++--- 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e83dd5a0..bf722812 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Dataiku Headless is an MCP server with tools for working in Dataiku, plus skills that teach AI assistants how to use them. Connect it to a Dataiku instance, and your AI assistant can build data pipelines, models, dashboards, agents, and more. -Install it from the [Claude Code](#claude-code-cli) or [Codex](#codex-cli) plugin marketplace, or install it as an agent plugin from this GitHub repository for Cursor, Snowflake CoCo, AWS Kiro, OpenCode, and more. +Install it from the [Claude Code](#claude-code-cli) or [Codex](#codex-cli) plugin marketplace. The repository also ships an [Agent Plugins](https://agent-plugins.org/) v1.0.0 package for Cursor and other conforming clients. ## Requirements @@ -47,7 +47,7 @@ Here, we use the Claude Code CLI to build a visual pipeline to clean up hospital ## Install with another agent -`dataiku-headless` also works with Snowflake CoCo (Cortex Code), Cursor, OpenCode, and custom MCP-compatible agents. Each plugin starts the same local MCP server; after installation, use the same setup flow above. +`dataiku-headless` also works with Cursor, OpenCode, and other Agent Plugins-compatible clients. The portable package uses the root `plugin.json`, root `mcp.json`, and the shared `skills/` directory; dedicated manifests remain for Claude Code and Codex. > **First launch:** If Dataiku Headless tools are unavailable, first check that `uv` is installed and on your `PATH`: > diff --git a/mcp.json b/mcp.json index 117029a8..09b3b452 100644 --- a/mcp.json +++ b/mcp.json @@ -4,7 +4,13 @@ "dataiku": { "type": "stdio", "command": "uv", - "args": ["run", "--quiet", "--locked", "--script", "./bin/run_mcp.py"], + "args": [ + "run", + "--quiet", + "--locked", + "--script", + "${PLUGIN_ROOT}/runtime/run_mcp.py" + ], "cwd": "${PLUGIN_ROOT}" } } diff --git a/plugin.json b/plugin.json index ff27d9b2..c8eb588c 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "dataiku-headless", - "version": "0.3.0", + "version": "0.6.0", "description": "Connect your agent to Dataiku: inspect projects, datasets, recipes, ML, and agents with typed MCP tools, and drive Dataiku Cobuild to build project-level assets.", "author": { "name": "Dataiku", diff --git a/pyproject.toml b/pyproject.toml index 954c49a2..459b11ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,12 @@ packages = ["dataiku_mcp"] include = [ "dataiku_mcp", "skills", + "runtime", + "plugin.json", + ".claude-plugin", + ".codex-plugin", + ".mcp.json", + "mcp.json", "README.md", "pyproject.toml", ] diff --git a/tests/test_agent_plugins.py b/tests/test_agent_plugins.py index 01fde450..9e9ff758 100644 --- a/tests/test_agent_plugins.py +++ b/tests/test_agent_plugins.py @@ -90,7 +90,7 @@ def test_portable_mcp_config_is_agent_plugins_v1_stdio(): "--quiet", "--locked", "--script", - "./bin/run_mcp.py", + "${PLUGIN_ROOT}/runtime/run_mcp.py", ] cwd = server.get("cwd") @@ -117,7 +117,10 @@ def test_skill_is_discovered_as_immediate_child_of_skills(): for p in (ROOT / "skills").iterdir() if p.is_dir() and (p / "SKILL.md").is_file() ] - assert [p.name for p in skill_dirs] == ["dataiku-headless"] + assert [p.name for p in skill_dirs] == [ + "dataiku-headless", + "dataiku-headless-setup", + ] frontmatter = skill_md.read_text(encoding="utf-8").split("---", 2) assert len(frontmatter) >= 3, "SKILL.md missing YAML frontmatter" @@ -129,7 +132,8 @@ def test_mcp_script_path_exists_in_package(): """Portable mcp.json must point at the supported script entry point.""" config = _load_json(ROOT / "mcp.json") server = config["mcpServers"]["dataiku"] - assert (ROOT / server["args"][-1]).is_file() + script_path = server["args"][-1].removeprefix("${PLUGIN_ROOT}/") + assert (ROOT / script_path).is_file() def test_plugin_versions_match_project_version(): From d1cb7e2dab4eb6c6f67a85f71594418d67bc8fb5 Mon Sep 17 00:00:00 2001 From: pmasiphelps Date: Thu, 10 Sep 2026 09:35:01 -0400 Subject: [PATCH 12/12] fix(tests): stabilize portable skill discovery --- tests/test_agent_plugins.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_agent_plugins.py b/tests/test_agent_plugins.py index 9e9ff758..1fd60ec7 100644 --- a/tests/test_agent_plugins.py +++ b/tests/test_agent_plugins.py @@ -1,3 +1,17 @@ +# Copyright 2026 Dataiku SAS +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Agent Plugins v1.0.0 portable package contract. This repo ships as an Agent Plugins package (root ``plugin.json`` + ``mcp.json`` @@ -117,7 +131,7 @@ def test_skill_is_discovered_as_immediate_child_of_skills(): for p in (ROOT / "skills").iterdir() if p.is_dir() and (p / "SKILL.md").is_file() ] - assert [p.name for p in skill_dirs] == [ + assert sorted(p.name for p in skill_dirs) == [ "dataiku-headless", "dataiku-headless-setup", ]