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..66396ddc 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -74,20 +74,24 @@ 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 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,16 +113,17 @@ 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: | 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 @@ -139,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/.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..1b5302c1 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,8 +24,8 @@ 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`. -- Plugin launch configuration: `.mcp.json`, `.claude-plugin/plugin.json`, and `.codex-plugin/plugin.json`. +- Standalone server dependency pins and Python floor: the PEP 723 block in `runtime/run_mcp.py`. +- 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`. @@ -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. @@ -78,6 +78,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/CODING_STANDARDS_AND_STRUCTURE.md b/CODING_STANDARDS_AND_STRUCTURE.md index 7a95d8bc..a6481bb4 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. @@ -97,14 +98,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..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`: > @@ -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 @@ -101,13 +107,15 @@ 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 } } } ``` +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: @@ -193,7 +201,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,10 +260,12 @@ 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 +├── 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..8c69936a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -18,11 +18,14 @@ 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` 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. --- @@ -48,9 +51,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/mcp.json b/mcp.json new file mode 100644 index 00000000..09b3b452 --- /dev/null +++ b/mcp.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "dataiku": { + "type": "stdio", + "command": "uv", + "args": [ + "run", + "--quiet", + "--locked", + "--script", + "${PLUGIN_ROOT}/runtime/run_mcp.py" + ], + "cwd": "${PLUGIN_ROOT}" + } + } +} diff --git a/plugin.json b/plugin.json new file mode 100644 index 00000000..c8eb588c --- /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.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", + "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 d2ae5a92..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", ] @@ -67,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/bin/launcher.sh b/runtime/launcher.sh similarity index 92% rename from bin/launcher.sh rename to runtime/launcher.sh index cea1b3a9..6d08aee7 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 @@ -30,7 +30,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 @@ -45,11 +45,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/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_agent_plugins.py b/tests/test_agent_plugins.py new file mode 100644 index 00000000..1fd60ec7 --- /dev/null +++ b/tests/test_agent_plugins.py @@ -0,0 +1,181 @@ +# 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`` ++ ``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 importlib.metadata +import json +import re +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])?$") + +# 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: + # 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") + + 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"] == "uv" + assert isinstance(server.get("args"), list) + assert server["args"] == [ + "run", + "--quiet", + "--locked", + "--script", + "${PLUGIN_ROOT}/runtime/run_mcp.py", + ] + + cwd = server.get("cwd") + if cwd is not None: + assert _CWD_RE.match(cwd), cwd + assert ".." not in cwd + + +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 sorted(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" + 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_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"] + script_path = server["args"][-1].removeprefix("${PLUGIN_ROOT}/") + assert (ROOT / script_path).is_file() + + +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_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 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