From 1241d6370ca8cfff0335f13ae2cebe06924bc078 Mon Sep 17 00:00:00 2001 From: crmapj Date: Mon, 9 Nov 2026 14:31:38 +0000 Subject: [PATCH 1/2] feat(recipes): select a Python recipe's code environment Add set_recipe_code_env, a narrow Design-only direct write that changes only params.envSelection of one existing Python recipe (INHERIT, EXPLICIT_ENV, or USE_BUILTIN_MODE). Cobuild cannot change this selection. The tool refuses non-Python recipes, recipes without a selection, and names that are not Python environments on the instance before saving, then returns the previous and read-back selections. Routing convention, capability matrix, tool-surface contract, and the stock skill references are updated together. Co-Authored-By: Claude Opus 5.5 --- CODING_STANDARDS_AND_STRUCTURE.md | 4 +- README.md | 4 +- dataiku_mcp/tools/recipes.py | 103 +++++- docs/capabilities.md | 22 +- skills/dataiku-headless/SKILL.md | 4 +- .../administration/code-environments.md | 2 +- skills/dataiku-headless/references/recipes.md | 41 ++- .../recipes/recipe-types/code-recipes.md | 2 +- tests/test_instance_policy.py | 1 + tests/test_recipe_code_env.py | 297 ++++++++++++++++++ tests/test_tool_surface.py | 4 +- 11 files changed, 457 insertions(+), 27 deletions(-) create mode 100644 tests/test_recipe_code_env.py diff --git a/CODING_STANDARDS_AND_STRUCTURE.md b/CODING_STANDARDS_AND_STRUCTURE.md index 3e25be0b..984dda37 100644 --- a/CODING_STANDARDS_AND_STRUCTURE.md +++ b/CODING_STANDARDS_AND_STRUCTURE.md @@ -103,9 +103,9 @@ export DKU_API_KEY="your-api-key" - Give a fixed-value parameter a `Literal` so its allowed values ride in the schema, and drop the `require_allowed_value` check it replaces. Keep the check and describe the values in prose instead when it normalizes input (for example `.upper()`), since a `Literal` would reject values that work today, or when the value set is large enough that listing it in every catalog send costs more than a description. ## Cobuild Write-Routing Convention -- This server intentionally does not expose direct create/update/delete tools for in-project flow and analytic assets (recipes, ML analyses, dashboards, insights, agents, agent tools, scenarios, webapps, wiki articles, data quality rules, knowledge banks, semantic models, evaluation stores). Project-level building goes through `dataiku_mcp/tools/cobuild.py`'s Cobuild conversation tools instead. `set_container_exec_config` is the narrow, placement-only exception; all other changes stay with Cobuild. +- This server intentionally does not expose direct create/update/delete tools for in-project flow and analytic assets (recipes, ML analyses, dashboards, insights, agents, agent tools, scenarios, webapps, wiki articles, data quality rules, knowledge banks, semantic models, evaluation stores). Project-level building goes through `dataiku_mcp/tools/cobuild.py`'s Cobuild conversation tools instead. `set_container_exec_config` and `set_recipe_code_env` are the narrow, selection-only exceptions; all other changes stay with Cobuild. - Do not add a new direct write tool for an in-project asset type beyond the fixed exceptions below. If a gap in Cobuild's coverage is found, note it in the relevant SKILL.md rather than adding an MCP write tool around it. -- A new direct write tool is only justified when the operation is cross-project, instance-level, or must happen before a project/Cobuild conversation exists. The fixed bootstrap exceptions are `create_project`, `create_upload_dataset`, `create_managed_folder`, `upload_file_to_managed_folder`, `write_project_library_file`, and `set_project_variables`; all but `create_project` write into a project, and each creates a container or carries local content rather than building logic. The other fixed exceptions are deterministic execution of existing assets (`build_datasets`, `run_recipe`, `run_scenario`, `abort_job`, which stops an existing job, and `get_dataset_metrics`, which recomputes a dataset's existing metric probes so a read does not return stale stored values) and deterministic container execution placement for an existing object (`set_container_exec_config`). +- A new direct write tool is only justified when the operation is cross-project, instance-level, or must happen before a project/Cobuild conversation exists. The fixed bootstrap exceptions are `create_project`, `create_upload_dataset`, `create_managed_folder`, `upload_file_to_managed_folder`, `write_project_library_file`, and `set_project_variables`; all but `create_project` write into a project, and each creates a container or carries local content rather than building logic. The other fixed exceptions are deterministic execution of existing assets (`build_datasets`, `run_recipe`, `run_scenario`, `abort_job`, which stops an existing job, and `get_dataset_metrics`, which recomputes a dataset's existing metric probes so a read does not return stale stored values), deterministic container execution placement for an existing object (`set_container_exec_config`), and deterministic code-environment selection for an existing Python recipe (`set_recipe_code_env`), which Cobuild cannot change. ## Fixed Tool Surface diff --git a/README.md b/README.md index 3622fb27..50fc23d5 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ npx skills add dataiku/dataiku-headless Dataiku Headless is an MCP server and agent skill library for operating Dataiku from an AI agent. Connect it to a Dataiku instance to inspect projects, gather context, and use Cobuild—Dataiku's agent for building data pipelines, analytics, machine learning models, multi-agent workflows, applications, and automation pipelines. -Cobuild runs as a retained conversation through MCP tools. This repository intentionally keeps its own tool surface small: inspection tools, three deterministic executions of existing assets (`build_datasets`, `run_recipe`, and `run_scenario`), narrow configuration actions such as selecting which container an existing object runs in, and a few bootstrap actions that Cobuild cannot perform, such as creating a project or uploading a local file. +Cobuild runs as a retained conversation through MCP tools. This repository intentionally keeps its own tool surface small: inspection tools, three deterministic executions of existing assets (`build_datasets`, `run_recipe`, and `run_scenario`), narrow configuration actions such as selecting which container an existing object runs in or which code environment an existing Python recipe uses, and a few bootstrap actions that Cobuild cannot perform, such as creating a project or uploading a local file. ## Capability reference @@ -304,7 +304,7 @@ The Dataiku Headless marketplace plugin uses stdio transport. For customer-manag │ │ ├── webapps.py # WebApp/backend-state inspection tools │ │ ├── wikis.py # Wiki article inspection tools │ │ ├── project_libraries.py # Project library inspection/search + local-file write -│ │ ├── recipes.py # Recipe inspection tools +│ │ ├── recipes.py # Recipe inspection and Python code-env selection │ │ ├── machine_learning/ # ML analysis/saved-model inspection tools │ │ └── utils/ # Tool validation and response-shaping utilities │ ├── config/ # Models, stdio/HTTP configuration, and request routing diff --git a/dataiku_mcp/tools/recipes.py b/dataiku_mcp/tools/recipes.py index 828d0a19..b74ec224 100644 --- a/dataiku_mcp/tools/recipes.py +++ b/dataiku_mcp/tools/recipes.py @@ -12,19 +12,32 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Recipe inspection tools.""" +"""Recipe inspection tools and Python recipe code-environment selection.""" from __future__ import annotations -from typing import Any +from typing import Annotated, Any, Literal from fastmcp import Context +from pydantic import Field -from ..instance_policy import PROJECT_READ_POLICY +from ..instance_policy import PROJECT_EDIT_POLICY, PROJECT_READ_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking from .utils.serialization import columnar, compact_json +from .utils.validation import require_non_empty_string + +PythonEnvMode = Literal["INHERIT", "EXPLICIT_ENV", "USE_BUILTIN_MODE"] +PythonEnvName = Annotated[ + str | None, + Field( + description=( + "Python environment name from list_code_envs, copied exactly; " + "only with env_mode='EXPLICIT_ENV'." + ) + ), +] CODE_RECIPE_TYPES = { "python", @@ -112,6 +125,40 @@ def _settings_view(settings, include_engine_params: bool = False) -> dict: return result +def _python_env_selection(settings, recipe_name: str) -> dict: + """Return the live code-environment selection of one Python recipe. + + The selection is a reference into the settings, so replacing it in the + params and calling ``settings.save()`` writes back only the environment + choice; the code, inputs, outputs, and other params are saved unchanged. + """ + recipe_type = _get_recipe_type(settings) + if recipe_type != "python": + raise ValueError( + f"Recipe '{recipe_name}' is a '{recipe_type}' recipe; only Python " + "recipes are supported" + ) + params = settings.get_recipe_params() + selection = params.get("envSelection") if isinstance(params, dict) else None + if not isinstance(selection, dict): + raise ValueError( + f"Recipe '{recipe_name}' does not expose a code environment selection" + ) + return selection + + +def _require_python_env(client, env_name: str) -> None: + """Refuse an unknown name, which Dataiku can store and only reject at run time.""" + if not any( + env.get("envName") == env_name and env.get("envLang") == "PYTHON" + for env in client.list_code_envs() + ): + raise ValueError( + f"No Python code environment named '{env_name}'. Use " + "list_code_envs(language='PYTHON') and copy a returned name exactly" + ) + + @mcp.tool( meta=PROJECT_READ_POLICY, title="List Recipes", @@ -162,3 +209,53 @@ def _run(): ) return compact_json(await run_blocking(_run)) + + +@mcp.tool( + meta=PROJECT_EDIT_POLICY, + title="Set Recipe Code Environment", + annotations={ + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": True, + "openWorldHint": False, + }, +) +async def set_recipe_code_env( + project_key: str, + recipe_name: str, + env_mode: PythonEnvMode, + ctx: Context, + env_name: PythonEnvName = None, +) -> str: + """Select which code environment one existing Python recipe runs with.""" + project_key = require_non_empty_string(project_key, "project_key") + recipe_name = require_non_empty_string(recipe_name, "recipe_name") + if env_mode == "EXPLICIT_ENV": + env_name = require_non_empty_string(env_name, "env_name") + selection = {"envMode": env_mode, "envName": env_name} + elif env_name is not None: + raise ValueError("'env_name' is only valid with env_mode='EXPLICIT_ENV'") + else: + selection = {"envMode": env_mode} + await ctx.info( + f"Setting the code environment of recipe '{recipe_name}' in {project_key}..." + ) + + def _run(): + client = get_dss_client() + recipe = client.get_project(project_key).get_recipe(recipe_name) + settings = recipe.get_settings() + previous = dict(_python_env_selection(settings, recipe_name)) + if env_mode == "EXPLICIT_ENV": + _require_python_env(client, env_name) + settings.get_recipe_params()["envSelection"] = selection + settings.save() + return { + "project_key": project_key, + "recipe_name": recipe_name, + "previous_env_selection": previous, + "env_selection": _python_env_selection(recipe.get_settings(), recipe_name), + } + + return compact_json(await run_blocking(_run)) diff --git a/docs/capabilities.md b/docs/capabilities.md index e3eeef8c..7a56c5dd 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -11,7 +11,9 @@ its object-specific references as directed. or change recipe logic, code, inputs, or outputs, or to create or modify the definition of an ML analysis, dashboard, insight, agent, agent tool, scenario, webapp, wiki article, data quality rule, knowledge bank, semantic model, or evaluation store. The container -execution override below changes only which container an existing object runs in. +execution override below changes only which container an existing object runs in, and +the Python recipe code-environment selection changes only which environment an existing +Python recipe runs with. Headless writes Dataiku objects directly in four cases: **bootstrap** (get a project or local content onto the instance so Cobuild has something to work with), **project @@ -37,8 +39,8 @@ Dataiku permissions and API availability still apply to permitted calls. Automation excludes all six Cobuild tools, even conversation listing and polling, and `create_project`, `update_project_settings`, `set_project_variables`, -`set_container_exec_config`, `create_upload_dataset`, `create_managed_folder`, -`upload_file_to_managed_folder`, `write_project_library_file`. +`set_container_exec_config`, `set_recipe_code_env`, `create_upload_dataset`, +`create_managed_folder`, `upload_file_to_managed_folder`, `write_project_library_file`. It permits `build_datasets`, `run_recipe`, `run_scenario`, `abort_job`, metric recomputation through `get_dataset_metrics`, and every project-folder operation, including creation, deletion, and moving projects. @@ -56,13 +58,13 @@ test. Design and Automation require a successful DSS connection test in local se ## Surface -**131 tools** · 93 read · 24 direct Dataiku write · 6 Cobuild · 4 execute · 3 local +**132 tools** · 93 read · 25 direct Dataiku write · 6 Cobuild · 4 execute · 3 local profile · 1 connection test | Bucket | # | Scope | |---|---|---| | Read / inspect | 93 | Never mutates | -| Direct Dataiku write | 24 | Bootstrap, project configuration, cross-project, admin | +| Direct Dataiku write | 25 | Bootstrap, project configuration, cross-project, admin | | Cobuild conversation | 6 | All flow and analytic building | | Execute | 5 | `build_datasets`, `run_recipe`, `run_scenario`, `abort_job`, `get_dataset_metrics` (recomputes metrics before reading them) | | Local profile action | 3 | `configure_instance`, `switch_instance`, `delete_instance` | @@ -74,14 +76,15 @@ bucket here. ## Built by Cobuild — limited direct exceptions -Three partial exceptions: Headless can create a dataset from a local file, create an -empty managed folder, and select container execution for an existing object (see -*Handled directly by Headless*). It cannot build or change analytic logic here. +Four partial exceptions: Headless can create a dataset from a local file, create an +empty managed folder, select container execution for an existing object, and select the +code environment of an existing Python recipe (see *Handled directly by Headless*). It +cannot build or change analytic logic here. | Area | Inspect | Direct action | |---|---|---| | Datasets | `list_datasets`, `get_dataset_info`, `get_dataset_sample`, `get_dataset_profile`, `get_dataset_metrics`, `get_dataset_column_descriptions`, `export_dataset` | `build_datasets` | -| Recipes | `list_recipes`, `get_recipe_settings` | `set_container_exec_config`, `run_recipe` | +| Recipes | `list_recipes`, `get_recipe_settings` | `set_container_exec_config`, `set_recipe_code_env` (Python recipes), `run_recipe` | | Flow & zones | `get_flow_graph`, `list_flow_zones`, `get_flow_object_metadata` | — | | ML analyses | `list_ml_analyses`, `get_ml_analysis_summary`, `get_ml_analysis_settings`, `list_ml_analysis_models`, `get_ml_model_details` | `set_container_exec_config` (single-ML-task analyses) | | Saved models | `list_saved_models`, `list_saved_model_versions`, `get_saved_model_version_details` | — | @@ -115,6 +118,7 @@ No Cobuild involved. Scope says what kind of access, and where a write lands. |---|---|---|---| | Projects | `count_projects`, `list_projects`, `get_project_metadata`, `get_project_variables`, `get_project_settings` | `create_project`, `set_project_variables`, `update_project_settings` | Bootstrap and project configuration — direct writes are **in-project** | | Container execution placement | `get_recipe_settings`, `get_ml_analysis_settings`, `get_webapp_settings`, `get_knowledge_bank_settings`, `get_agent_tool_settings`, `list_container_exec_configs` | `set_container_exec_config` | Project configuration, **in-project** — sets which container a recipe, ML task, WebApp backend, Knowledge Bank, or agent tool runs in. Saved-model retrains use their training recipe's `params.containerSelection`. | +| Python recipe code environment | `get_recipe_settings`, `list_code_envs` | `set_recipe_code_env` | Project configuration, **in-project** — selects the inherited, an explicit, or the built-in environment for one existing Python recipe. Its code, inputs, outputs, and other settings are unchanged. | | Uploaded Files datasets | — | `create_upload_dataset` | Bootstrap, **in-project** — supplied rows in HTTP; local files in stdio only | | Managed folders | `list_managed_folders`, `get_managed_folder_info`, `get_managed_folder_contents` | `create_managed_folder`, `upload_file_to_managed_folder` | Bootstrap, **in-project** — local-file upload is stdio only | | Project libraries | `list_project_library`, `read_project_library_file`, `search_project_library`, `validate_project_library_file` | `write_project_library_file` | Bootstrap, **in-project** — local-file write is stdio only | diff --git a/skills/dataiku-headless/SKILL.md b/skills/dataiku-headless/SKILL.md index 1353336c..52ea23e4 100644 --- a/skills/dataiku-headless/SKILL.md +++ b/skills/dataiku-headless/SKILL.md @@ -30,7 +30,8 @@ Use this for any Dataiku task. Choose the right reference guide first, inspect t Automation excludes all Cobuild tools, including conversation listing and polling, and project creation, project settings/variables updates, container placement, -dataset/folder creation, file upload, and library writes. Builds, recipe/scenario +recipe code-environment selection, dataset/folder creation, file upload, and library +writes. Builds, recipe/scenario runs, and job aborts remain available and can change outputs. For project construction or edits, ask which Design instance to use and switch to it; do not bypass the policy through raw APIs. @@ -64,6 +65,7 @@ unsupported calls fail before execution. | Inspect datasets, schema, samples, or metrics; export a local CSV; or create an Uploaded Files dataset | `./references/datasets.md` | | Inspect recipes, choose a recipe family, or ground a flow transformation | `./references/recipes.md` | | Select which container one existing recipe, ML task, WebApp backend, Knowledge Bank, or agent tool runs in | `./references/container-execution.md` | +| Select which code environment one existing Python recipe runs with | `./references/recipes.md` | | Run an existing dataset or recipe, or track jobs, waits, and logs | `./references/jobs.md` | | Inspect managed folders, create one on a chosen connection, or upload a user-supplied local file into one | `./references/managed_folders.md` | | Inspect the project library or write one user-supplied local source file | `./references/project-libraries.md` | diff --git a/skills/dataiku-headless/references/administration/code-environments.md b/skills/dataiku-headless/references/administration/code-environments.md index 3c50bb66..f06cd64e 100644 --- a/skills/dataiku-headless/references/administration/code-environments.md +++ b/skills/dataiku-headless/references/administration/code-environments.md @@ -25,7 +25,7 @@ Matching a workload's language does not establish package or runtime compatibili 6. Create only managed Design-node `PYTHON` or `R` environments. Package-spec changes are also limited to managed Design-node environments; permissions, build targets, and rebuilds can be updated on other environment types. 7. Requested-package changes automatically update the local environment and rebuild images. Container or Spark Kubernetes target changes rebuild images. Use `force_rebuild=true` only when a clean local environment rebuild is intended; it does not rebuild images by itself. 8. Use `delete_code_env` when deletion is requested. It checks Dataiku usages first and returns any blocking PROJECT, NOTEBOOK, SCENARIO_STEP, or other usage records with remediation guidance; do not infer that deletion succeeded until `deleted` is true. -9. Route recipe, ML analysis, and code-agent environment selection changes through `../cobuild.md`. +9. To select the environment of an existing Python recipe, use `set_recipe_code_env` as described in `../recipes.md`. Route other recipe, ML analysis, and code-agent environment selection changes through `../cobuild.md`. ## Permissions diff --git a/skills/dataiku-headless/references/recipes.md b/skills/dataiku-headless/references/recipes.md index be2bbc6a..61101a07 100644 --- a/skills/dataiku-headless/references/recipes.md +++ b/skills/dataiku-headless/references/recipes.md @@ -32,9 +32,25 @@ A recipe's outputs must fit the surrounding Flow. Inspect input schemas and exis 4. Read the matching recipe-family reference before interpreting a type-specific configuration or describing a new recipe to Cobuild. 5. Read supporting guides when the selected recipe depends on managed folders, models, LLMs, Knowledge Banks, agents, code environments, project libraries, or connections. 6. If the request only changes where an existing recipe runs, use `./container-execution.md`. This is a narrow direct-write exception and does not require Cobuild. -7. Route all other recipe creation, editing, and wiring through `./cobuild.md`. -8. Cobuild will run recipes and build datasets as part of broader project work. For one-off execution of an existing recipe or dataset build, use `./jobs.md`: `build_datasets` for dataset outputs and `run_recipe` for direct recipe execution. -9. After a build or run starts, use `./jobs.md` to follow an active or uncertain job. Validate outputs through the relevant guide. +7. If the request only changes which code environment an existing Python recipe runs with, follow [Python Recipe Code Environment](#python-recipe-code-environment). This is also a narrow direct-write exception. +8. Route all other recipe creation, editing, and wiring through `./cobuild.md`. +9. Cobuild will run recipes and build datasets as part of broader project work. For one-off execution of an existing recipe or dataset build, use `./jobs.md`: `build_datasets` for dataset outputs and `run_recipe` for direct recipe execution. +10. After a build or run starts, use `./jobs.md` to follow an active or uncertain job. Validate outputs through the relevant guide. + +## Python Recipe Code Environment + +`set_recipe_code_env` requires `design`; it is unavailable on Automation. + +`set_recipe_code_env` changes only the code environment selection of one existing `python` recipe. It preserves the recipe's code, inputs, outputs, container selection, and every other setting. Cobuild cannot change this selection. Route environment changes for other recipe types through `./cobuild.md`. + +1. Inspect the recipe with `get_recipe_settings`. Confirm that its `type` is `python` and read `params.envSelection`. +2. Choose one mode: + - `INHERIT`: use the project default (see `./projects/settings.md`); omit `env_name`. + - `USE_BUILTIN_MODE`: use the instance's built-in Python environment; omit `env_name`. + - `EXPLICIT_ENV`: select one environment and pass `env_name`. +3. Before using `EXPLICIT_ENV`, call `list_code_envs(language="PYTHON")` and copy the returned name exactly. Select an explicit environment only when the user names one or its compatibility is established; see `./administration/code-environments.md`. +4. Call `set_recipe_code_env`. It returns `previous_env_selection` and the `env_selection` read back after saving. Keep the previous selection so that you can restore it on request. Confirm the change with `get_recipe_settings`. +5. Run the recipe through `./jobs.md` only when the user asks. A successful run is the evidence that the environment fits the code. ## Recipe Families @@ -69,6 +85,8 @@ A recipe's outputs must fit the surrounding Flow. Inspect input schemas and exis - `get_flow_graph` - `list_recipes` - `get_recipe_settings` +- `set_recipe_code_env` +- `list_code_envs` - `list_datasets` - `get_dataset_info` - `get_dataset_profile` @@ -77,12 +95,21 @@ A recipe's outputs must fit the surrounding Flow. Inspect input schemas and exis ## Safety Rules - Inspect an existing recipe before any modification. -- Treat container execution selection, documented in `./container-execution.md`, as the - only direct recipe-setting write. Route changes to recipe logic, code, inputs, outputs, - or other engine settings through Cobuild. +- Treat container execution selection, documented in `./container-execution.md`, and + Python recipe code-environment selection, documented above, as the only direct + recipe-setting writes. Route changes to recipe logic, code, inputs, outputs, or other + engine settings through Cobuild. +- `set_recipe_code_env` saves nothing when the recipe is not a `python` recipe, exposes no + environment selection, or names no Python environment on the instance. Dataiku can also + refuse the save when the project prevents overriding its default environment or the + user cannot use the environment. Report the condition; do not change project settings + to bypass it unless the user asks. +- Dataiku replaces the whole recipe on save. An edit someone else makes between the + tool's read and its write is lost, so avoid it while another person edits that recipe. - Preserve the surrounding Flow's storage and dependency context unless the user requests a change. - Treat a timed-out or interrupted build as potentially still running; inspect the job before retrying or changing related Flow objects. - `get_flow_graph` is the primary flow-orientation tool. It returns flow sources, nodes, and dependency edges. On large flows those lists may come back clipped; when that affects the task, use the relevant `list_*` tools for context and inspect only the specific datasets, recipes, or flow objects that matter with the relevant `get_*` tools. - Use `./jobs.md` for direct execution of an existing recipe or its dataset outputs; this guide stays focused on inspection and grounded recipe planning. - Keep this skill focused on inspection, concepts, and Cobuild grounding. Do not add - direct recipe mutation workflows beyond the container execution exception. + direct recipe mutation workflows beyond the container execution and Python + code-environment exceptions. diff --git a/skills/dataiku-headless/references/recipes/recipe-types/code-recipes.md b/skills/dataiku-headless/references/recipes/recipe-types/code-recipes.md index 5140c154..aed8452f 100644 --- a/skills/dataiku-headless/references/recipes/recipe-types/code-recipes.md +++ b/skills/dataiku-headless/references/recipes/recipe-types/code-recipes.md @@ -21,6 +21,6 @@ Read this reference only after the user explicitly requests a code-based transfo ## Selection Notes - Inspect existing code before requesting a change to an existing Code recipe. -- Select a code environment from discovered names only when explicit environment selection is needed. +- Select a code environment from discovered names only when explicit environment selection is needed. For an existing Python recipe, change the selection with `set_recipe_code_env` as described in `../../recipes.md`. - Use project-library context when the recipe depends on shared source files. - For SQL, inspect dataset and storage context before describing the intended query behavior. diff --git a/tests/test_instance_policy.py b/tests/test_instance_policy.py index 0e549048..db9bb08f 100644 --- a/tests/test_instance_policy.py +++ b/tests/test_instance_policy.py @@ -36,6 +36,7 @@ "update_project_settings", "set_project_variables", "set_container_exec_config", + "set_recipe_code_env", "create_upload_dataset", "create_managed_folder", "upload_file_to_managed_folder", diff --git a/tests/test_recipe_code_env.py b/tests/test_recipe_code_env.py new file mode 100644 index 00000000..a6cb1fa0 --- /dev/null +++ b/tests/test_recipe_code_env.py @@ -0,0 +1,297 @@ +# 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. + +"""Unit tests for selecting a Python recipe's code environment.""" + +import asyncio +import copy +import json + +import pytest +from fastmcp import Client + +from dataiku_mcp import mcp +from dataiku_mcp.config import stdio +from dataiku_mcp.config.models import DSSInstance +from dataiku_mcp.tools import recipes + +from tests.utils.fakes import FakeContext, FakeDSSClient + +CODE = "import dataiku\nprint('unchanged')\n" +ENVS = [ + {"envName": "py-analytics", "envLang": "PYTHON"}, + {"envName": "r-only", "envLang": "R"}, +] + + +class _FakeRecipe: + """Server-side recipe state, handed out as detached settings copies. + + A mutation only becomes visible to a later read once ``save`` commits it, + so the tool's post-save read-back proves the write happened. + """ + + def __init__(self, state: dict): + self.state = state + self.save_calls = 0 + self.raise_on_save: Exception | None = None + + def get_settings(self): + return _FakeRecipeSettings(self, copy.deepcopy(self.state)) + + +class _FakeRecipeSettings: + def __init__(self, server: _FakeRecipe, state: dict): + self.server = server + self.state = state + + def get_recipe_raw_definition(self) -> dict: + return self.state["recipe"] + + def get_recipe_params(self): + return self.state["recipe"].get("params") + + def save(self) -> None: + if self.server.raise_on_save is not None: + raise self.server.raise_on_save + self.server.save_calls += 1 + self.server.state = copy.deepcopy(self.state) + + +class _FakeProject: + def __init__(self, recipe: _FakeRecipe): + self.recipe = recipe + + def get_recipe(self, recipe_name: str) -> _FakeRecipe: + assert recipe_name == "compute_scores" + return self.recipe + + +class _FakeClient(FakeDSSClient): + def __init__(self, recipe: _FakeRecipe, envs: list[dict]): + super().__init__({"PROJ": _FakeProject(recipe)}) + self.envs = envs + self.list_code_envs_calls = 0 + + def list_code_envs(self) -> list[dict]: + self.list_code_envs_calls += 1 + return copy.deepcopy(self.envs) + + +def _recipe_state(recipe_type: str = "python", params: dict | None = None) -> dict: + if params is None: + params = { + "envSelection": {"envMode": "INHERIT"}, + "containerSelection": {"containerMode": "NONE"}, + "skipPrerunValidate": False, + } + return { + "recipe": { + "name": "compute_scores", + "type": recipe_type, + "params": params, + "inputs": {"main": {"items": [{"ref": "raw_scores"}]}}, + "outputs": {"main": {"items": [{"ref": "scores", "appendMode": False}]}}, + "tags": ["kept"], + }, + "payload": CODE, + } + + +def _install(monkeypatch, state: dict, envs: list[dict] = ENVS): + recipe = _FakeRecipe(state) + client = _FakeClient(recipe, envs) + monkeypatch.setattr(recipes, "get_dss_client", lambda: client) + return recipe, client + + +def _set_env(env_mode: str, env_name: str | None = None) -> dict: + return json.loads( + asyncio.run( + recipes.set_recipe_code_env( + "PROJ", "compute_scores", env_mode, FakeContext(), env_name + ) + ) + ) + + +def test_explicit_env_changes_only_the_selection(monkeypatch): + state = _recipe_state() + before = copy.deepcopy(state) + recipe, client = _install(monkeypatch, state) + + result = _set_env("EXPLICIT_ENV", "py-analytics") + + assert result == { + "project_key": "PROJ", + "recipe_name": "compute_scores", + "previous_env_selection": {"envMode": "INHERIT"}, + "env_selection": {"envMode": "EXPLICIT_ENV", "envName": "py-analytics"}, + } + expected = copy.deepcopy(before) + expected["recipe"]["params"]["envSelection"] = { + "envMode": "EXPLICIT_ENV", + "envName": "py-analytics", + } + assert recipe.state == expected + assert recipe.save_calls == 1 + assert client.list_code_envs_calls == 1 + + +@pytest.mark.parametrize("env_mode", ["INHERIT", "USE_BUILTIN_MODE"]) +def test_non_explicit_modes_clear_the_env_name(monkeypatch, env_mode): + state = _recipe_state() + state["recipe"]["params"]["envSelection"] = { + "envMode": "EXPLICIT_ENV", + "envName": "py-analytics", + } + recipe, client = _install(monkeypatch, state) + + result = _set_env(env_mode) + + assert result["previous_env_selection"] == { + "envMode": "EXPLICIT_ENV", + "envName": "py-analytics", + } + assert result["env_selection"] == {"envMode": env_mode} + assert recipe.state["recipe"]["params"]["envSelection"] == {"envMode": env_mode} + assert recipe.state["payload"] == CODE + assert recipe.save_calls == 1 + # Only an explicit name needs the environment catalog. + assert client.list_code_envs_calls == 0 + + +@pytest.mark.parametrize("env_name", ["missing-env", "r-only"]) +def test_unknown_or_non_python_env_is_refused_before_saving(monkeypatch, env_name): + recipe, _ = _install(monkeypatch, _recipe_state()) + + with pytest.raises(ValueError, match="No Python code environment"): + _set_env("EXPLICIT_ENV", env_name) + + assert recipe.save_calls == 0 + assert recipe.state == _recipe_state() + + +@pytest.mark.parametrize( + "recipe_type", ["r", "pyspark", "sql_query", "sync", "CustomCode_scorer"] +) +def test_only_python_recipes_are_supported(monkeypatch, recipe_type): + recipe, client = _install(monkeypatch, _recipe_state(recipe_type)) + + with pytest.raises(ValueError, match=f"'{recipe_type}' recipe"): + _set_env("EXPLICIT_ENV", "py-analytics") + + assert recipe.save_calls == 0 + assert client.list_code_envs_calls == 0 + + +@pytest.mark.parametrize("params", [{}, None, {"envSelection": "INHERIT"}]) +def test_recipe_without_a_selection_is_refused(monkeypatch, params): + state = _recipe_state(params=params) + if params is None: + del state["recipe"]["params"] + recipe, _ = _install(monkeypatch, state) + + with pytest.raises(ValueError, match="does not expose"): + _set_env("INHERIT") + + assert recipe.save_calls == 0 + + +@pytest.mark.parametrize( + ("env_mode", "env_name", "message"), + [ + ("EXPLICIT_ENV", None, "env_name"), + ("EXPLICIT_ENV", " ", "env_name"), + ("INHERIT", "py-analytics", "only valid"), + ("USE_BUILTIN_MODE", "py-analytics", "only valid"), + ], +) +def test_invalid_arguments_are_refused_without_dataiku_calls( + monkeypatch, env_mode, env_name, message +): + monkeypatch.setattr( + recipes, "get_dss_client", lambda: pytest.fail("No SDK call is allowed") + ) + + with pytest.raises(ValueError, match=message): + _set_env(env_mode, env_name) + + +def test_rejected_save_leaves_the_recipe_untouched(monkeypatch): + """Dataiku refuses a save when the project prevents overrides or the user lacks use.""" + recipe, _ = _install(monkeypatch, _recipe_state()) + recipe.raise_on_save = RuntimeError("Selection of code env is prevented") + + with pytest.raises(RuntimeError, match="prevented"): + _set_env("EXPLICIT_ENV", "py-analytics") + + assert recipe.state == _recipe_state() + + +def _instance(instance_type: str) -> DSSInstance: + return DSSInstance( + name=instance_type, + url="https://example.com", + api_key="private-key", + api_ticket=None, + instance_type=instance_type, + source="config", + no_check_certificate=False, + ) + + +def test_mcp_schema_rejects_unknown_env_mode(monkeypatch): + monkeypatch.setattr(stdio, "_current_instance", _instance("design")) + monkeypatch.setattr( + recipes, "get_dss_client", lambda: pytest.fail("No SDK call is allowed") + ) + + async def dispatch(): + async with Client(mcp) as client: + return await client.call_tool( + "set_recipe_code_env", + { + "project_key": "PROJ", + "recipe_name": "compute_scores", + "env_mode": "EXPLICIT_CONTAINER", + }, + raise_on_error=False, + ) + + result = asyncio.run(dispatch()) + assert result.is_error + assert "env_mode" in result.content[0].text + + +def test_mcp_dispatch_saves_on_design(monkeypatch): + monkeypatch.setattr(stdio, "_current_instance", _instance("design")) + recipe, _ = _install(monkeypatch, _recipe_state()) + + async def dispatch(): + async with Client(mcp) as client: + return await client.call_tool( + "set_recipe_code_env", + { + "project_key": "PROJ", + "recipe_name": "compute_scores", + "env_mode": "USE_BUILTIN_MODE", + }, + ) + + result = asyncio.run(dispatch()) + assert json.loads(result.content[0].text)["env_selection"] == { + "envMode": "USE_BUILTIN_MODE" + } + assert recipe.save_calls == 1 diff --git a/tests/test_tool_surface.py b/tests/test_tool_surface.py index c3312899..88264664 100644 --- a/tests/test_tool_surface.py +++ b/tests/test_tool_surface.py @@ -208,7 +208,9 @@ "update_project_settings", } ), - "recipes": frozenset({"get_recipe_settings", "list_recipes"}), + "recipes": frozenset( + {"get_recipe_settings", "list_recipes", "set_recipe_code_env"} + ), "scenarios": frozenset( { "get_scenario_run_history", From 062fa223ff90a2638b7d2acec1ad715152869978 Mon Sep 17 00:00:00 2001 From: crmapj Date: Mon, 9 Nov 2026 15:23:44 +0000 Subject: [PATCH 2/2] refactor(recipes): clarify code-env tool title and tests Title the tool for Python recipes, which are the only type it accepts, make the missing-selection test cases explicit, and reflow the Automation paragraph in the root skill. Co-Authored-By: Claude Opus 5.5 --- dataiku_mcp/tools/recipes.py | 2 +- skills/dataiku-headless/SKILL.md | 5 ++--- tests/test_recipe_code_env.py | 25 +++++++++++++------------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/dataiku_mcp/tools/recipes.py b/dataiku_mcp/tools/recipes.py index b74ec224..0fb0cbfd 100644 --- a/dataiku_mcp/tools/recipes.py +++ b/dataiku_mcp/tools/recipes.py @@ -213,7 +213,7 @@ def _run(): @mcp.tool( meta=PROJECT_EDIT_POLICY, - title="Set Recipe Code Environment", + title="Set Python Recipe Code Environment", annotations={ "readOnlyHint": False, "destructiveHint": True, diff --git a/skills/dataiku-headless/SKILL.md b/skills/dataiku-headless/SKILL.md index 52ea23e4..59a71ac0 100644 --- a/skills/dataiku-headless/SKILL.md +++ b/skills/dataiku-headless/SKILL.md @@ -31,9 +31,8 @@ Use this for any Dataiku task. Choose the right reference guide first, inspect t Automation excludes all Cobuild tools, including conversation listing and polling, and project creation, project settings/variables updates, container placement, recipe code-environment selection, dataset/folder creation, file upload, and library -writes. Builds, recipe/scenario -runs, and job aborts remain available and can change outputs. For project -construction or edits, ask which Design instance to use and +writes. Builds, recipe/scenario runs, and job aborts remain available and can change +outputs. For project construction or edits, ask which Design instance to use and switch to it; do not bypass the policy through raw APIs. Agent Management, Govern and Deployer report `connection_status=unsupported` without a DSS probe. diff --git a/tests/test_recipe_code_env.py b/tests/test_recipe_code_env.py index a6cb1fa0..cd19c684 100644 --- a/tests/test_recipe_code_env.py +++ b/tests/test_recipe_code_env.py @@ -89,18 +89,16 @@ def list_code_envs(self) -> list[dict]: return copy.deepcopy(self.envs) -def _recipe_state(recipe_type: str = "python", params: dict | None = None) -> dict: - if params is None: - params = { - "envSelection": {"envMode": "INHERIT"}, - "containerSelection": {"containerMode": "NONE"}, - "skipPrerunValidate": False, - } +def _recipe_state(recipe_type: str = "python") -> dict: return { "recipe": { "name": "compute_scores", "type": recipe_type, - "params": params, + "params": { + "envSelection": {"envMode": "INHERIT"}, + "containerSelection": {"containerMode": "NONE"}, + "skipPrerunValidate": False, + }, "inputs": {"main": {"items": [{"ref": "raw_scores"}]}}, "outputs": {"main": {"items": [{"ref": "scores", "appendMode": False}]}}, "tags": ["kept"], @@ -196,11 +194,14 @@ def test_only_python_recipes_are_supported(monkeypatch, recipe_type): assert client.list_code_envs_calls == 0 -@pytest.mark.parametrize("params", [{}, None, {"envSelection": "INHERIT"}]) +@pytest.mark.parametrize( + "params", + [None, {}, {"envSelection": "INHERIT"}], + ids=["no-params", "no-selection", "malformed-selection"], +) def test_recipe_without_a_selection_is_refused(monkeypatch, params): - state = _recipe_state(params=params) - if params is None: - del state["recipe"]["params"] + state = _recipe_state() + state["recipe"]["params"] = params recipe, _ = _install(monkeypatch, state) with pytest.raises(ValueError, match="does not expose"):