From c2d5536dcf005bc36ef3e3e07bb36db65efe9ecb Mon Sep 17 00:00:00 2001 From: Pierre Portal Date: Tue, 15 Sep 2026 18:39:32 +0200 Subject: [PATCH 1/9] feat(govern): add a single govern tool over the Govern Python SDK --- README.md | 14 +- dataiku_mcp/__init__.py | 1 + dataiku_mcp/config.py | 68 + dataiku_mcp/setup_server.py | 87 +- dataiku_mcp/tools/govern.py | 1918 +++++++++++++++++ dataiku_mcp/tools/instances.py | 10 +- dataiku_mcp/tools/utils/auth.py | 33 + docs/capabilities.md | 14 +- skills/dataiku-headless-setup/SKILL.md | 4 +- skills/dataiku-headless/SKILL.md | 7 +- skills/dataiku-headless/references/govern.md | 149 ++ .../references/govern/artifacts.md | 74 + .../references/govern/blueprints.md | 99 + .../references/govern/signoffs.md | 91 + .../references/govern/supporting-objects.md | 91 + tests/test_tool_surface.py | 1 + 16 files changed, 2629 insertions(+), 32 deletions(-) create mode 100644 dataiku_mcp/tools/govern.py create mode 100644 skills/dataiku-headless/references/govern.md create mode 100644 skills/dataiku-headless/references/govern/artifacts.md create mode 100644 skills/dataiku-headless/references/govern/blueprints.md create mode 100644 skills/dataiku-headless/references/govern/signoffs.md create mode 100644 skills/dataiku-headless/references/govern/supporting-objects.md diff --git a/README.md b/README.md index 047b6b5c..9dbaf483 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ ## About Dataiku Headless -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. +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. Connect a Dataiku Govern node too, and it can inspect and manage governed artifacts, blueprints, and signoffs through one `govern` tool. 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. @@ -128,9 +128,15 @@ For a quick reference to what Headless can inspect, what Cobuild builds, and the limited direct actions Headless supports, see the [Headless capability matrix](docs/capabilities.md). +Dataiku Govern is reached through a single `govern` tool: a fixed catalog of +operations over the public Govern Python SDK, listed by the tool itself. The Govern +node has its own URL and API key, entered on the `configure_instance` page or through +`DKU_GOVERN_URL` and `DKU_GOVERN_API_KEY`. See the +[Govern guide](skills/dataiku-headless/references/govern.md). + ## MCP Server -`dataiku_mcp` is a FastMCP server that exposes Dataiku operations as typed, async MCP tools. Tools are organized by domain: projects, project folders, flow, connections, datasets, data quality, managed folders, recipes, machine learning, insights, dashboards, scenarios, WebApps, wikis, agents, LLMs and knowledge banks, instance plugins, job management, administrative tasks, and Cobuild conversations. +`dataiku_mcp` is a FastMCP server that exposes Dataiku operations as typed, async MCP tools. Tools are organized by domain: projects, project folders, flow, connections, datasets, data quality, managed folders, recipes, machine learning, insights, dashboards, scenarios, WebApps, wikis, agents, LLMs and knowledge banks, instance plugins, job management, administrative tasks, Cobuild conversations, and Dataiku Govern. - Async execution for all Dataiku API calls - Progress notifications for long-running operations @@ -144,7 +150,7 @@ Tools do not accept API keys as arguments — authentication is resolved server- `skills` exposes two prompt-based entrypoints: `dataiku-headless-setup` for first-time installation, runtime recovery, and instance configuration; and `dataiku-headless` for Dataiku work. The main entry skill decides which reference guide to read next, carries the shared operating rules, routes in-project asset changes through Cobuild by default, and documents the narrow direct-write exceptions for bootstrap, cross-project, instance-level, or administrative operations that Cobuild does not handle. -The reference library covers the main Dataiku object areas and workflows, including projects, project folders, datasets, recipes, jobs, connections, code environments, plugins, managed folders, project libraries, data quality, machine learning, agents, agent reviews, scenarios, semantic models, webapps, wikis, dashboards, insights, data collections, cross-project sharing, and migrations. +The reference library covers the main Dataiku object areas and workflows, including projects, project folders, datasets, recipes, jobs, connections, code environments, plugins, managed folders, project libraries, data quality, machine learning, agents, agent reviews, scenarios, semantic models, webapps, wikis, dashboards, insights, data collections, cross-project sharing, migrations, and Govern. ## Onboarding and authentication @@ -214,6 +220,7 @@ uv run --quiet --locked --script ./runtime/run_mcp.py # same command the plugi │ │ ├── datasets.py # Dataset inspection tools + local-file upload writes │ │ ├── evaluation_stores.py # Evaluation Store inspection tools │ │ ├── flow.py # Flow inspection tools +│ │ ├── govern.py # Single `govern` tool: fixed operation catalog over the Govern Python SDK │ │ ├── instances.py # Multi-instance switching tools │ │ ├── jobs.py # Async job status/log/wait/abort tools │ │ ├── llms_and_knowledge_banks.py # LLM, Knowledge Bank, and RAG inspection tools @@ -250,6 +257,7 @@ uv run --quiet --locked --script ./runtime/run_mcp.py # same command the plugi │ ├── connections.md # Connection discovery and capability inspection │ ├── machine-learning.md # ML analysis, trained-model, and saved-model inspection │ ├── agents.md # Agent and agent-tool inspection +│ ├── govern.md # Govern entry guide; govern/ holds the artifact, blueprint, signoff, and supporting-object guides │ ├── ... # Additional references for dashboards, insights, scenarios, wikis, migrations, and more │ └── recipes/ # Nested recipe-family and shared recipe references ├── runtime/ diff --git a/dataiku_mcp/__init__.py b/dataiku_mcp/__init__.py index 402d9a56..2a3d62ec 100644 --- a/dataiku_mcp/__init__.py +++ b/dataiku_mcp/__init__.py @@ -65,6 +65,7 @@ async def on_call_tool( evaluation_stores, flow, general_settings, + govern, groups, insights, instances, diff --git a/dataiku_mcp/config.py b/dataiku_mcp/config.py index f26ec33d..08f2888d 100644 --- a/dataiku_mcp/config.py +++ b/dataiku_mcp/config.py @@ -30,6 +30,20 @@ class DSSInstance: no_check_certificate: bool source: str description: str = "" + govern_url: str = "" + govern_api_key: str = "" + govern_no_check_certificate: bool = False + + +@dataclass(frozen=True) +class GovernConnection: + """Resolved Govern node credentials for the govern tool.""" + + url: str + api_key: str + no_check_certificate: bool + source: str + instance_name: str = "" @dataclass @@ -112,6 +126,41 @@ def _load_instance_from_env_vars() -> DSSInstance | None: os.environ.get("DKU_NO_CHECK_CERTIFICATE", "") ), source="environment", + govern_url=os.environ.get("DKU_GOVERN_URL", ""), + govern_api_key=os.environ.get("DKU_GOVERN_API_KEY", ""), + govern_no_check_certificate=_parse_no_check_certificate( + os.environ.get("DKU_GOVERN_NO_CHECK_CERTIFICATE", "") + ), + ) + + +def get_govern_connection_from_env() -> GovernConnection | None: + """Return the Govern node defined by `DKU_GOVERN_URL`, or None. + + Environment variables win over the active instance profile so a CI job or + a quick test can target a Govern node without editing the config file. + """ + govern_url = os.environ.get("DKU_GOVERN_URL", "").strip() + if not govern_url: + return None + return GovernConnection( + url=govern_url, + api_key=os.environ.get("DKU_GOVERN_API_KEY", ""), + no_check_certificate=_parse_no_check_certificate( + os.environ.get("DKU_GOVERN_NO_CHECK_CERTIFICATE", "") + ), + source="environment", + ) + + +def govern_connection_for_instance(instance: DSSInstance) -> GovernConnection: + """Return the Govern node stored on an instance profile (URL may be empty).""" + return GovernConnection( + url=instance.govern_url, + api_key=instance.govern_api_key, + no_check_certificate=instance.govern_no_check_certificate, + source=instance.source, + instance_name=instance.name, ) @@ -146,6 +195,11 @@ def _load_config() -> DSSConfig: description=details.get("description", ""), no_check_certificate=details.get("no_check_certificate", False), source="config", + govern_url=details.get("govern_url", ""), + govern_api_key=details.get("govern_api_key", ""), + govern_no_check_certificate=details.get( + "govern_no_check_certificate", False + ), ) return DSSConfig(default_instance_name, dss_instances) @@ -161,6 +215,12 @@ def _save_config(config: DSSConfig) -> None: } if instance.description: serialized_instance["description"] = instance.description + if instance.govern_url or instance.govern_api_key: + serialized_instance["govern_url"] = instance.govern_url + serialized_instance["govern_api_key"] = instance.govern_api_key + serialized_instance["govern_no_check_certificate"] = ( + instance.govern_no_check_certificate + ) serialized_instances[name] = serialized_instance data = { @@ -260,6 +320,7 @@ def set_current_instance(name: str) -> dict: "name": _current_instance.name, "url": _current_instance.url, "description": _current_instance.description, + "govern_url": _current_instance.govern_url, } @@ -271,6 +332,9 @@ def add_instance_to_config( description: str = "", no_check_certificate: bool = False, set_default: bool = False, + govern_url: str = "", + govern_api_key: str = "", + govern_no_check_certificate: bool = False, ) -> dict: """Add an instance to the resolved config file.""" new_instance = DSSInstance( @@ -280,6 +344,9 @@ def add_instance_to_config( description=description, no_check_certificate=no_check_certificate, source="config", + govern_url=govern_url, + govern_api_key=govern_api_key, + govern_no_check_certificate=govern_no_check_certificate, ) config = _load_config() @@ -294,6 +361,7 @@ def add_instance_to_config( "name": name, "url": url, "description": description, + "govern_url": govern_url, "path": str(get_config_path()), "default_instance": config.default_instance, } diff --git a/dataiku_mcp/setup_server.py b/dataiku_mcp/setup_server.py index 0e5898bc..db3ce295 100644 --- a/dataiku_mcp/setup_server.py +++ b/dataiku_mcp/setup_server.py @@ -47,7 +47,7 @@ class SetupSession: browser_opened: bool completed: threading.Event result: dict | None = None - validated_connection: tuple[str, str, bool] | None = None + validated_connection: tuple | None = None expired: bool = False def close(self) -> None: @@ -57,36 +57,46 @@ def close(self) -> None: self.server.server_close() -def _validate_form(form: dict[str, list[str]]) -> dict: - name = form.get("name", [""])[0].strip() - url = form.get("url", [""])[0].strip() - api_key = form.get("api_key", [""])[0].strip() - description = form.get("description", [""])[0].strip() - - if not name: - raise ValueError("Instance name is required.") - if len(name) > 80 or any(ord(character) < 32 for character in name): - raise ValueError("Instance name must be 80 characters or fewer.") +def _validate_url(url: str, label: str) -> str: if "\\" in url or any( ord(character) < 32 or character.isspace() for character in url ): - raise ValueError("Instance URL contains invalid whitespace or backslashes.") + raise ValueError(f"{label} contains invalid whitespace or backslashes.") try: parsed_url = urlsplit(url) port = parsed_url.port if parsed_url.netloc.endswith(":") or port == 0: raise ValueError except ValueError: - raise ValueError( - "Instance URL is malformed or contains an invalid port." - ) from None + raise ValueError(f"{label} is malformed or contains an invalid port.") from None if parsed_url.scheme not in {"http", "https"} or not parsed_url.hostname: - raise ValueError("Instance URL must be a complete http:// or https:// URL.") + raise ValueError(f"{label} must be a complete http:// or https:// URL.") if parsed_url.username or parsed_url.password: - raise ValueError("Instance URL cannot contain credentials.") - url = f"{parsed_url.scheme}://{parsed_url.netloc}" + raise ValueError(f"{label} cannot contain credentials.") + return f"{parsed_url.scheme}://{parsed_url.netloc}" + + +def _validate_form(form: dict[str, list[str]]) -> dict: + name = form.get("name", [""])[0].strip() + url = form.get("url", [""])[0].strip() + api_key = form.get("api_key", [""])[0].strip() + description = form.get("description", [""])[0].strip() + govern_url = form.get("govern_url", [""])[0].strip() + govern_api_key = form.get("govern_api_key", [""])[0].strip() + + if not name: + raise ValueError("Instance name is required.") + if len(name) > 80 or any(ord(character) < 32 for character in name): + raise ValueError("Instance name must be 80 characters or fewer.") + url = _validate_url(url, "Instance URL") if not api_key: raise ValueError("API key is required.") + if govern_url: + govern_url = _validate_url(govern_url, "Govern URL") + if not govern_api_key: + raise ValueError("Govern API key is required when a Govern URL is set.") + elif govern_api_key: + raise ValueError("Govern URL is required when a Govern API key is set.") return { "name": name, @@ -95,6 +105,9 @@ def _validate_form(form: dict[str, list[str]]) -> dict: "description": description, "no_check_certificate": "no_check_certificate" in form, "set_default": "set_default" in form, + "govern_url": govern_url, + "govern_api_key": govern_api_key, + "govern_no_check_certificate": "govern_no_check_certificate" in form, } @@ -119,6 +132,12 @@ def _page( api_key = escape(values.get("api_key", "")) set_default = " checked" if is_initial_page or values.get("set_default") else "" no_check_certificate = " checked" if values.get("no_check_certificate") else "" + govern_url = escape(values.get("govern_url", "")) + govern_api_key = escape(values.get("govern_api_key", "")) + govern_no_check_certificate = ( + " checked" if values.get("govern_no_check_certificate") else "" + ) + govern_open = " open" if govern_url or govern_api_key else "" save_disabled = "" if connection_validated else " disabled" return f""" @@ -156,6 +175,7 @@ def _page( details {{ margin-top:16px; }} summary {{ color:var(--muted); cursor:pointer; font-size:13px; font-weight:650; }} details .check {{ margin-top:12px; }} + details .grid {{ margin-top:12px; }} .actions {{ display:grid; grid-template-columns:1fr 1fr; gap:12px; }} button {{ width:100%; border:0; border-radius:8px; padding:12px 16px; color:white; background:var(--teal-dark); font:inherit; font-weight:700; line-height:1.2; cursor:pointer; transition:background-color 150ms ease, box-shadow 150ms ease; }} button:hover {{ background:#00696c; }} @@ -188,6 +208,13 @@ def _page(
Advanced options
+ Govern node (optional) +
+
Leave empty when this instance has no Govern node. The govern tool uses it.
+
Create one in Govern under Administration → API keys.
+
+ + {status_markup}
@@ -237,10 +264,30 @@ def _test_connection(values: dict) -> None: raise ValueError( "Could not connect. Check the URL, API key, and certificate settings." ) from exc + if not values["govern_url"]: + return + try: + govern_client = dataikuapi.GovernClient( + values["govern_url"], values["govern_api_key"] + ) + govern_client._session.verify = not values["govern_no_check_certificate"] + govern_client.get_auth_info() + except Exception as exc: + raise ValueError( + "Could not connect to the Govern node. Check the Govern URL, API key, " + "and certificate settings." + ) from exc -def _connection_settings(values: dict) -> tuple[str, str, bool]: - return values["url"], values["api_key"], values["no_check_certificate"] +def _connection_settings(values: dict) -> tuple: + return ( + values["url"], + values["api_key"], + values["no_check_certificate"], + values["govern_url"], + values["govern_api_key"], + values["govern_no_check_certificate"], + ) def _make_handler(token: str, expected_host: str, session_state: SetupSession | None): diff --git a/dataiku_mcp/tools/govern.py b/dataiku_mcp/tools/govern.py new file mode 100644 index 00000000..b73d8bf4 --- /dev/null +++ b/dataiku_mcp/tools/govern.py @@ -0,0 +1,1918 @@ +# 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. + +"""Single entry point to Dataiku Govern through the public Python SDK. + +One tool, ``govern``, exposes a fixed catalog of operations. Each operation +maps by hand to one ``dataikuapi.GovernClient`` call chain, so the agent never +guesses SDK methods, REST paths, or payload envelopes. An empty ``operation`` +returns the catalog itself. +""" + +from __future__ import annotations + +import hashlib +import os +import tempfile +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from dataikuapi.govern.artifact_search import ( + GovernArtifactFilterArchivedStatus, + GovernArtifactFilterArtifacts, + GovernArtifactFilterBlueprints, + GovernArtifactFilterBlueprintVersions, + GovernArtifactFilterFieldValue, + GovernArtifactSearchQuery, + GovernArtifactSearchSortField, + GovernArtifactSearchSortFieldDefinition, + GovernArtifactSearchSortName, + GovernArtifactSearchSortWorkflow, +) +from fastmcp import Context + +from .. import mcp +from .utils.async_executor import run_blocking +from .utils.auth import get_govern_client +from .utils.serialization import compact_json +from .utils.validation import require_non_empty_string + +DOMAINS = ( + "artifacts", + "signoffs", + "blueprints", + "roles", + "custom_pages", + "time_series", + "files", + "users", + "instance", +) + +_CHUNK_SIZE = 1024 * 1024 + + +@dataclass(frozen=True) +class GovernParam: + name: str + kind: str # string, boolean, integer, object, list, any + description: str + required: bool = False + + +@dataclass(frozen=True) +class GovernOperation: + domain: str + kind: str # read, write, delete + description: str + sdk: str + params: tuple[GovernParam, ...] + run: Callable[[Any, dict[str, Any]], Any] + + +GOVERN_OPERATIONS: dict[str, GovernOperation] = {} + + +def _register( + operation_id: str, + *, + domain: str, + kind: str, + description: str, + sdk: str, + params: tuple[GovernParam, ...] = (), +): + def decorator(func): + GOVERN_OPERATIONS[operation_id] = GovernOperation( + domain, kind, description, sdk, tuple(params), func + ) + return func + + return decorator + + +def _p(name: str, kind: str, description: str, required: bool = False) -> GovernParam: + return GovernParam(name, kind, description, required) + + +ARTIFACT_ID = _p("artifact_id", "string", "Artifact ID, for example ar.42.", True) +STEP_ID = _p("step_id", "string", "Workflow step ID that carries the signoff.", True) +BLUEPRINT_ID = _p( + "blueprint_id", + "string", + "Blueprint ID, for example bp.system.govern_project.", + True, +) +VERSION_ID = _p( + "version_id", "string", "Blueprint version ID, for example bv.system.default.", True +) +ROLE_ID = _p("role_id", "string", "Role ID, for example ro.reviewer.", True) +CUSTOM_PAGE_ID = _p( + "custom_page_id", "string", "Custom page ID, for example cp.1.", True +) +TIME_SERIES_ID = _p( + "time_series_id", "string", "Time series ID, for example ts.7.", True +) +UPLOADED_FILE_ID = _p( + "uploaded_file_id", "string", "Uploaded file ID, for example uf.3.", True +) +LOGIN = _p("login", "string", "User login.", True) +GROUP_NAME = _p("name", "string", "Group name.", True) +NEW_IDENTIFIER = _p( + "new_identifier", + "string", + "Bare identifier for the new object; the server adds the type prefix.", + True, +) +USERS_CONTAINER = _p( + "users_container", + "object", + "Users container: {type: user|group|role|global-api-key, login|groupName|roleId|keyId}.", + True, +) +COMMENT = _p("comment", "string", "Optional comment recorded with the decision.") + + +class _UsersContainer: + """Adapter so a raw users-container dict satisfies the SDK build() contract.""" + + def __init__(self, definition: dict[str, Any]): + self.definition = definition + + def build(self) -> dict[str, Any]: + return self.definition + + +def _users_container(value: Any) -> _UsersContainer: + if not isinstance(value, dict) or not value.get("type"): + raise ValueError( + "users_container must be an object with a 'type' key: " + "user, group, role or global-api-key" + ) + return _UsersContainer(value) + + +def _replace_raw(definition, new_raw: dict[str, Any]): + """Replace a mutable SDK definition in place so ``save()`` sends ``new_raw``.""" + raw = definition.get_raw() + raw.clear() + raw.update(new_raw) + return definition + + +def _version_state(version) -> dict[str, Any]: + return { + "definition": version.get_definition().get_raw(), + "trace": version.get_trace().get_raw(), + } + + +def _signoff(client, params: dict[str, Any]): + return client.get_artifact(params["artifact_id"]).get_signoff(params["step_id"]) + + +def _admin_blueprint(client, params: dict[str, Any]): + return client.get_blueprint_designer().get_blueprint(params["blueprint_id"]) + + +def _admin_version(client, params: dict[str, Any]): + return _admin_blueprint(client, params).get_version(params["version_id"]) + + +def _roles(client): + return client.get_roles_permissions_handler() + + +def _pages(client): + return client.get_custom_pages_handler() + + +# --------------------------------------------------------------------------- +# artifacts +# --------------------------------------------------------------------------- + + +@_register( + "search_artifacts", + domain="artifacts", + kind="read", + description="Search artifacts with optional blueprint, version, artifact, field and archived filters.", + sdk="GovernClient.new_artifact_search_request", + params=( + _p("blueprint_ids", "list", "Blueprint IDs to keep."), + _p( + "blueprint_version_ids", + "list", + "Blueprint version IDs to keep, each {blueprintId, versionId}.", + ), + _p("artifact_ids", "list", "Artifact IDs to keep."), + _p( + "field_filters", + "list", + "Field conditions, each {condition_type: EQUALS|CONTAINS|START_WITH|END_WITH, condition, field_id, negate, case_sensitive}; no field_id filters on the name.", + ), + _p("archived", "boolean", "true includes archived artifacts."), + _p( + "sort", + "object", + "Sort: {type: name|workflow|field, direction: ASC|DESC, fields: [{blueprint_id, field_id}]}.", + ), + _p("page_size", "integer", "Batch size per request (default 20)."), + _p("max_results", "integer", "Maximum hits to return (default 100)."), + ), +) +def _search_artifacts(client, p): + filters = [] + if p.get("blueprint_ids"): + filters.append(GovernArtifactFilterBlueprints(list(p["blueprint_ids"]))) + if p.get("blueprint_version_ids"): + filters.append( + GovernArtifactFilterBlueprintVersions(list(p["blueprint_version_ids"])) + ) + if p.get("artifact_ids"): + filters.append(GovernArtifactFilterArtifacts(list(p["artifact_ids"]))) + for field_filter in p.get("field_filters") or []: + if not isinstance(field_filter, dict) or not field_filter.get("condition_type"): + raise ValueError("Each field_filters entry needs a condition_type") + filters.append( + GovernArtifactFilterFieldValue( + field_filter["condition_type"], + condition=field_filter.get("condition"), + field_id=field_filter.get("field_id"), + negate_condition=field_filter.get("negate"), + case_sensitive=field_filter.get("case_sensitive"), + ) + ) + if p.get("archived") is not None: + filters.append(GovernArtifactFilterArchivedStatus(bool(p["archived"]))) + + sort = None + sort_spec = p.get("sort") + if sort_spec: + sort_type = sort_spec.get("type", "name") + direction = sort_spec.get("direction", "ASC") + if sort_type == "name": + sort = GovernArtifactSearchSortName(direction) + elif sort_type == "workflow": + sort = GovernArtifactSearchSortWorkflow(direction) + elif sort_type == "field": + fields = [ + GovernArtifactSearchSortFieldDefinition( + field["blueprint_id"], field["field_id"] + ) + for field in sort_spec.get("fields") or [] + ] + if not fields: + raise ValueError("sort.fields is required when sort.type is field") + sort = GovernArtifactSearchSortField(fields=fields, direction=direction) + else: + raise ValueError("sort.type must be name, workflow or field") + + page_size = int(p.get("page_size") or 20) + max_results = int(p.get("max_results") or 100) + if page_size <= 0 or max_results <= 0: + raise ValueError("page_size and max_results must be positive") + + request = client.new_artifact_search_request( + GovernArtifactSearchQuery(artifact_filters=filters, artifact_search_sort=sort) + ) + hits: list[dict[str, Any]] = [] + has_more = False + while True: + batch = request.fetch_next_batch(page_size=page_size).get_response_hits() + if not batch: + break + for hit in batch: + if len(hits) >= max_results: + has_more = True + break + hits.append(hit.get_raw()) + if has_more or len(batch) < page_size: + break + return {"count": len(hits), "has_more": has_more, "hits": hits} + + +@_register( + "get_artifact", + domain="artifacts", + kind="read", + description="Get an artifact definition: name, fields, workflow state and blueprint version.", + sdk="GovernArtifact.get_definition", + params=(ARTIFACT_ID,), +) +def _get_artifact(client, p): + return client.get_artifact(p["artifact_id"]).get_definition().get_raw() + + +@_register( + "create_artifact", + domain="artifacts", + kind="write", + description="Create an artifact on an ACTIVE blueprint version.", + sdk="GovernClient.create_artifact", + params=( + _p( + "blueprint_id", + "string", + "Blueprint ID. Required unless definition is given.", + ), + _p( + "version_id", + "string", + "ACTIVE blueprint version ID. Required unless definition is given.", + ), + _p("name", "string", "Artifact name. Required unless definition is given."), + _p( + "fields", + "object", + "Field values keyed by field ID; list fields take arrays.", + ), + _p( + "definition", + "object", + "Complete artifact payload; overrides the other params.", + ), + ), +) +def _create_artifact(client, p): + definition = p.get("definition") + if definition is None: + for key in ("blueprint_id", "version_id", "name"): + if not p.get(key): + raise ValueError(f"'{key}' is required when 'definition' is not given") + definition = { + "blueprintVersionId": { + "blueprintId": p["blueprint_id"], + "versionId": p["version_id"], + }, + "name": p["name"], + "fields": p.get("fields") or {}, + } + return client.create_artifact(definition).get_definition().get_raw() + + +@_register( + "update_artifact", + domain="artifacts", + kind="write", + description="Replace the complete artifact definition; read it first and send everything back.", + sdk="GovernArtifactDefinition.save", + params=( + ARTIFACT_ID, + _p( + "definition", + "object", + "Complete artifact definition from get_artifact.", + True, + ), + ), +) +def _update_artifact(client, p): + artifact = client.get_artifact(p["artifact_id"]) + _replace_raw(artifact.get_definition(), p["definition"]).save() + return artifact.get_definition().get_raw() + + +@_register( + "update_artifact_fields", + domain="artifacts", + kind="write", + description="Merge field values (and optionally the name) into an artifact, preserving the rest.", + sdk="GovernArtifactDefinition.save", + params=( + ARTIFACT_ID, + _p("fields", "object", "Field values to set, keyed by field ID.", True), + _p("name", "string", "New artifact name."), + ), +) +def _update_artifact_fields(client, p): + artifact = client.get_artifact(p["artifact_id"]) + definition = artifact.get_definition() + raw = definition.get_raw() + raw.setdefault("fields", {}).update(p["fields"]) + if p.get("name"): + raw["name"] = p["name"] + definition.save() + return artifact.get_definition().get_raw() + + +@_register( + "delete_artifact", + domain="artifacts", + kind="delete", + description="Delete an artifact.", + sdk="GovernArtifact.delete", + params=(ARTIFACT_ID,), +) +def _delete_artifact(client, p): + client.get_artifact(p["artifact_id"]).delete() + return {"deleted": p["artifact_id"]} + + +# --------------------------------------------------------------------------- +# signoffs +# --------------------------------------------------------------------------- + + +@_register( + "list_signoffs", + domain="signoffs", + kind="read", + description="List the signoffs of an artifact with their step and status.", + sdk="GovernArtifact.list_signoffs", + params=(ARTIFACT_ID,), +) +def _list_signoffs(client, p): + return [ + item.get_raw() for item in client.get_artifact(p["artifact_id"]).list_signoffs() + ] + + +@_register( + "get_signoff", + domain="signoffs", + kind="read", + description="Get a signoff definition: status, feedback responses and approver response.", + sdk="GovernArtifactSignoff.get_definition", + params=(ARTIFACT_ID, STEP_ID), +) +def _get_signoff(client, p): + return _signoff(client, p).get_definition().get_raw() + + +@_register( + "get_signoff_details", + domain="signoffs", + kind="read", + description="Get a signoff with reviewer and approver membership resolved to users.", + sdk="GovernArtifactSignoff.get_details", + params=(ARTIFACT_ID, STEP_ID), +) +def _get_signoff_details(client, p): + return _signoff(client, p).get_details().get_raw() + + +@_register( + "create_signoff", + domain="signoffs", + kind="write", + description="Create the signoff of an ONGOING step whose blueprint version has a signoff configuration.", + sdk="GovernArtifact.create_signoff", + params=(ARTIFACT_ID, STEP_ID), +) +def _create_signoff(client, p): + artifact = client.get_artifact(p["artifact_id"]) + return artifact.create_signoff(p["step_id"]).get_definition().get_raw() + + +@_register( + "update_signoff_status", + domain="signoffs", + kind="write", + description="Move a signoff to NOT_STARTED, WAITING_FOR_FEEDBACK, WAITING_FOR_APPROVAL, APPROVED, REJECTED or ABANDONED.", + sdk="GovernArtifactSignoff.update_status", + params=( + ARTIFACT_ID, + STEP_ID, + _p("status", "string", "Target signoff status.", True), + _p( + "users_to_notify", + "list", + "Users to email, each {userLogin, groupId}; omit to notify every configured reviewer or approver; pass [] to notify nobody.", + ), + _p( + "reload_conf_for_reset", + "boolean", + "On NOT_STARTED, reload the configuration from the blueprint version and drop delegations.", + ), + ), +) +def _update_signoff_status(client, p): + signoff = _signoff(client, p) + signoff.update_status( + p["status"], + users_to_notify=p.get("users_to_notify"), + reload_conf_for_reset=bool(p.get("reload_conf_for_reset", False)), + ) + return signoff.get_definition().get_raw() + + +@_register( + "list_signoff_feedbacks", + domain="signoffs", + kind="read", + description="List the feedback responses recorded on a signoff.", + sdk="GovernArtifactSignoff.list_feedbacks", + params=(ARTIFACT_ID, STEP_ID), +) +def _list_signoff_feedbacks(client, p): + return [item.get_raw() for item in _signoff(client, p).list_feedbacks()] + + +FEEDBACK_ID = _p("feedback_id", "string", "Feedback response ID.", True) +FEEDBACK_STATUS = _p( + "status", "string", "Feedback status: APPROVED, MINOR_ISSUE or MAJOR_ISSUE.", True +) +APPROVAL_STATUS = _p( + "status", "string", "Approval status: APPROVED, REJECTED or ABANDONED.", True +) + + +@_register( + "get_signoff_feedback", + domain="signoffs", + kind="read", + description="Get one feedback response of a signoff.", + sdk="GovernArtifactSignoffFeedback.get_definition", + params=(ARTIFACT_ID, STEP_ID, FEEDBACK_ID), +) +def _get_signoff_feedback(client, p): + return _signoff(client, p).get_feedback(p["feedback_id"]).get_definition().get_raw() + + +@_register( + "add_signoff_feedback", + domain="signoffs", + kind="write", + description="Record a feedback response as the authenticated user, in a feedback group.", + sdk="GovernArtifactSignoff.add_feedback", + params=( + ARTIFACT_ID, + STEP_ID, + _p( + "group_id", + "string", + "Feedback group ID from the signoff configuration.", + True, + ), + FEEDBACK_STATUS, + COMMENT, + ), +) +def _add_signoff_feedback(client, p): + feedback = _signoff(client, p).add_feedback( + p["group_id"], p["status"], comment=p.get("comment") + ) + return feedback.get_definition().get_raw() + + +@_register( + "update_signoff_feedback", + domain="signoffs", + kind="write", + description="Change the status or comment of an existing feedback response.", + sdk="GovernArtifactSignoffFeedbackDefinition.save", + params=(ARTIFACT_ID, STEP_ID, FEEDBACK_ID, FEEDBACK_STATUS, COMMENT), +) +def _update_signoff_feedback(client, p): + definition = _signoff(client, p).get_feedback(p["feedback_id"]).get_definition() + raw = definition.get_raw() + raw["status"] = p["status"] + if p.get("comment") is not None: + raw["comment"] = p["comment"] + definition.save() + return definition.get_raw() + + +@_register( + "delete_signoff_feedback", + domain="signoffs", + kind="delete", + description="Delete a feedback response.", + sdk="GovernArtifactSignoffFeedback.delete", + params=(ARTIFACT_ID, STEP_ID, FEEDBACK_ID), +) +def _delete_signoff_feedback(client, p): + _signoff(client, p).get_feedback(p["feedback_id"]).delete() + return {"deleted": p["feedback_id"]} + + +@_register( + "delegate_signoff_feedback", + domain="signoffs", + kind="write", + description="Add a delegated reviewer to a feedback group of a signoff.", + sdk="GovernArtifactSignoff.delegate_feedback", + params=( + ARTIFACT_ID, + STEP_ID, + _p("group_id", "string", "Feedback group ID.", True), + USERS_CONTAINER, + ), +) +def _delegate_signoff_feedback(client, p): + signoff = _signoff(client, p) + signoff.delegate_feedback(p["group_id"], _users_container(p["users_container"])) + return signoff.get_details().get_raw() + + +@_register( + "get_signoff_approval", + domain="signoffs", + kind="read", + description="Get the approver response of a signoff.", + sdk="GovernArtifactSignoffApproval.get_definition", + params=(ARTIFACT_ID, STEP_ID), +) +def _get_signoff_approval(client, p): + return _signoff(client, p).get_approval().get_definition().get_raw() + + +@_register( + "add_signoff_approval", + domain="signoffs", + kind="write", + description="Record the final approval decision as the authenticated user.", + sdk="GovernArtifactSignoff.add_approval", + params=(ARTIFACT_ID, STEP_ID, APPROVAL_STATUS, COMMENT), +) +def _add_signoff_approval(client, p): + signoff = _signoff(client, p) + signoff.add_approval(p["status"], comment=p.get("comment")) + return signoff.get_approval().get_definition().get_raw() + + +@_register( + "update_signoff_approval", + domain="signoffs", + kind="write", + description="Change the status or comment of the recorded approval.", + sdk="GovernArtifactSignoffApprovalDefinition.save", + params=(ARTIFACT_ID, STEP_ID, APPROVAL_STATUS, COMMENT), +) +def _update_signoff_approval(client, p): + definition = _signoff(client, p).get_approval().get_definition() + raw = definition.get_raw() + raw["status"] = p["status"] + if p.get("comment") is not None: + raw["comment"] = p["comment"] + definition.save() + return definition.get_raw() + + +@_register( + "delete_signoff_approval", + domain="signoffs", + kind="delete", + description="Delete the recorded approval of a signoff.", + sdk="GovernArtifactSignoffApproval.delete", + params=(ARTIFACT_ID, STEP_ID), +) +def _delete_signoff_approval(client, p): + _signoff(client, p).get_approval().delete() + return { + "deleted": "approval", + "artifact_id": p["artifact_id"], + "step_id": p["step_id"], + } + + +@_register( + "delegate_signoff_approval", + domain="signoffs", + kind="write", + description="Add a delegated approver to a signoff.", + sdk="GovernArtifactSignoff.delegate_approval", + params=(ARTIFACT_ID, STEP_ID, USERS_CONTAINER), +) +def _delegate_signoff_approval(client, p): + signoff = _signoff(client, p) + signoff.delegate_approval(_users_container(p["users_container"])) + return signoff.get_details().get_raw() + + +@_register( + "get_signoff_recurrence", + domain="signoffs", + kind="read", + description="Get the recurrence (scheduled reset) configuration of a signoff.", + sdk="GovernArtifactSignoff.get_recurrence_configuration", + params=(ARTIFACT_ID, STEP_ID), +) +def _get_signoff_recurrence(client, p): + return _signoff(client, p).get_recurrence_configuration().get_raw() + + +@_register( + "update_signoff_recurrence", + domain="signoffs", + kind="write", + description="Replace the recurrence configuration of a signoff: {activated, days, weeks, months, years, reloadConf}.", + sdk="GovernArtifactSignoffRecurrenceConfiguration.save", + params=( + ARTIFACT_ID, + STEP_ID, + _p("configuration", "object", "Complete recurrence configuration.", True), + ), +) +def _update_signoff_recurrence(client, p): + recurrence = _signoff(client, p).get_recurrence_configuration() + _replace_raw(recurrence, p["configuration"]).save() + return recurrence.get_raw() + + +# --------------------------------------------------------------------------- +# blueprints +# --------------------------------------------------------------------------- + + +@_register( + "list_blueprints", + domain="blueprints", + kind="read", + description="List readable blueprints; each item nests the blueprint under 'blueprint'.", + sdk="GovernClient.list_blueprints", +) +def _list_blueprints(client, p): + return [item.get_raw() for item in client.list_blueprints()] + + +@_register( + "get_blueprint", + domain="blueprints", + kind="read", + description="Get a blueprint: name, icon and colors.", + sdk="GovernBlueprint.get_definition", + params=(BLUEPRINT_ID,), +) +def _get_blueprint(client, p): + return client.get_blueprint(p["blueprint_id"]).get_definition().get_raw() + + +@_register( + "list_blueprint_versions", + domain="blueprints", + kind="read", + description="List the versions of a blueprint with their status.", + sdk="GovernBlueprint.list_versions", + params=(BLUEPRINT_ID,), +) +def _list_blueprint_versions(client, p): + return [ + item.get_raw() + for item in client.get_blueprint(p["blueprint_id"]).list_versions() + ] + + +@_register( + "get_blueprint_version", + domain="blueprints", + kind="read", + description="Get a blueprint version definition (fields, workflow, views, hooks) and its trace (status, origin).", + sdk="GovernBlueprintVersion.get_definition", + params=(BLUEPRINT_ID, VERSION_ID), +) +def _get_blueprint_version(client, p): + version = client.get_blueprint(p["blueprint_id"]).get_version(p["version_id"]) + return _version_state(version) + + +@_register( + "create_blueprint", + domain="blueprints", + kind="write", + description="Create a blueprint (designer). The body holds name, icon, color and backgroundColor, no id.", + sdk="GovernAdminBlueprintDesigner.create_blueprint", + params=(NEW_IDENTIFIER, _p("blueprint", "object", "Blueprint metadata.", True)), +) +def _create_blueprint(client, p): + designer = client.get_blueprint_designer() + blueprint = designer.create_blueprint(p["new_identifier"], p["blueprint"]) + return blueprint.get_definition().get_raw() + + +@_register( + "update_blueprint", + domain="blueprints", + kind="write", + description="Replace the blueprint metadata (name, icon, colors); versions are edited separately.", + sdk="GovernAdminBlueprintDefinition.save", + params=( + BLUEPRINT_ID, + _p("blueprint", "object", "Complete blueprint metadata.", True), + ), +) +def _update_blueprint(client, p): + blueprint = _admin_blueprint(client, p) + _replace_raw(blueprint.get_definition(), p["blueprint"]).save() + return blueprint.get_definition().get_raw() + + +@_register( + "delete_blueprint", + domain="blueprints", + kind="delete", + description="Delete a blueprint; its versions must hold no artifacts.", + sdk="GovernAdminBlueprint.delete", + params=(BLUEPRINT_ID,), +) +def _delete_blueprint(client, p): + _admin_blueprint(client, p).delete() + return {"deleted": p["blueprint_id"]} + + +@_register( + "create_blueprint_version", + domain="blueprints", + kind="write", + description="Create a DRAFT blueprint version, optionally forked from an origin version.", + sdk="GovernAdminBlueprint.create_version", + params=( + BLUEPRINT_ID, + NEW_IDENTIFIER, + _p("name", "string", "Display name of the version."), + _p( + "origin_version_id", + "string", + "Version to copy fields, workflow and views from.", + ), + ), +) +def _create_blueprint_version(client, p): + version = _admin_blueprint(client, p).create_version( + p["new_identifier"], + name=p.get("name"), + origin_version_id=p.get("origin_version_id"), + ) + return _version_state(version) + + +@_register( + "update_blueprint_version", + domain="blueprints", + kind="write", + description="Replace a blueprint version definition; danger_zone_accepted lets a field removal or type change discard artifact data.", + sdk="GovernAdminBlueprintVersionDefinition.save", + params=( + BLUEPRINT_ID, + VERSION_ID, + _p( + "definition", + "object", + "Complete version definition from get_blueprint_version.", + True, + ), + _p( + "danger_zone_accepted", + "boolean", + "Accept data loss on artifacts of this version.", + ), + ), +) +def _update_blueprint_version(client, p): + version = _admin_version(client, p) + definition = _replace_raw(version.get_definition(), p["definition"]) + definition.save(danger_zone_accepted=p.get("danger_zone_accepted")) + return _version_state(version) + + +@_register( + "set_blueprint_version_status", + domain="blueprints", + kind="write", + description="Set a blueprint version status: DRAFT, ACTIVE or ARCHIVED.", + sdk="GovernAdminBlueprintVersionTrace.set_status", + params=(BLUEPRINT_ID, VERSION_ID, _p("status", "string", "Target status.", True)), +) +def _set_blueprint_version_status(client, p): + version = _admin_version(client, p) + version.get_trace().set_status(p["status"]) + return version.get_trace().get_raw() + + +@_register( + "delete_blueprint_version", + domain="blueprints", + kind="delete", + description="Delete a blueprint version; it must hold no artifacts.", + sdk="GovernAdminBlueprintVersion.delete", + params=(BLUEPRINT_ID, VERSION_ID), +) +def _delete_blueprint_version(client, p): + _admin_version(client, p).delete() + return {"deleted": p["version_id"], "blueprint_id": p["blueprint_id"]} + + +@_register( + "list_signoff_configurations", + domain="blueprints", + kind="read", + description="List the signoff configurations (review gates) of a blueprint version.", + sdk="GovernAdminBlueprintVersion.list_signoff_configurations", + params=(BLUEPRINT_ID, VERSION_ID), +) +def _list_signoff_configurations(client, p): + return [ + item.get_raw() + for item in _admin_version(client, p).list_signoff_configurations() + ] + + +@_register( + "get_signoff_configuration", + domain="blueprints", + kind="read", + description="Get the signoff configuration of one workflow step.", + sdk="GovernAdminSignoffConfiguration.get_definition", + params=(BLUEPRINT_ID, VERSION_ID, STEP_ID), +) +def _get_signoff_configuration(client, p): + configuration = _admin_version(client, p).get_signoff_configuration(p["step_id"]) + return configuration.get_definition().get_raw() + + +@_register( + "create_signoff_configuration", + domain="blueprints", + kind="write", + description="Create a review gate on a workflow step: {title, mandatory, feedbackUsersGroups, approvers, recurrenceConfiguration}, no id.", + sdk="GovernAdminBlueprintVersion.create_signoff_configuration", + params=( + BLUEPRINT_ID, + VERSION_ID, + STEP_ID, + _p("configuration", "object", "Signoff configuration without an id.", True), + ), +) +def _create_signoff_configuration(client, p): + configuration = _admin_version(client, p).create_signoff_configuration( + p["step_id"], p["configuration"] + ) + return configuration.get_definition().get_raw() + + +@_register( + "update_signoff_configuration", + domain="blueprints", + kind="write", + description="Replace the signoff configuration of a workflow step.", + sdk="GovernAdminSignoffConfigurationDefinition.save", + params=( + BLUEPRINT_ID, + VERSION_ID, + STEP_ID, + _p("configuration", "object", "Complete signoff configuration.", True), + ), +) +def _update_signoff_configuration(client, p): + configuration = _admin_version(client, p).get_signoff_configuration(p["step_id"]) + _replace_raw(configuration.get_definition(), p["configuration"]).save() + return configuration.get_definition().get_raw() + + +@_register( + "delete_signoff_configuration", + domain="blueprints", + kind="delete", + description="Delete the signoff configuration of a workflow step.", + sdk="GovernAdminSignoffConfiguration.delete", + params=(BLUEPRINT_ID, VERSION_ID, STEP_ID), +) +def _delete_signoff_configuration(client, p): + _admin_version(client, p).get_signoff_configuration(p["step_id"]).delete() + return { + "deleted": p["step_id"], + "blueprint_id": p["blueprint_id"], + "version_id": p["version_id"], + } + + +# --------------------------------------------------------------------------- +# roles +# --------------------------------------------------------------------------- + + +@_register( + "list_roles", + domain="roles", + kind="read", + description="List roles.", + sdk="GovernAdminRolesPermissionsHandler.list_roles", +) +def _list_roles(client, p): + return [item.get_raw() for item in _roles(client).list_roles()] + + +@_register( + "get_role", + domain="roles", + kind="read", + description="Get a role definition.", + sdk="GovernAdminRole.get_definition", + params=(ROLE_ID,), +) +def _get_role(client, p): + return _roles(client).get_role(p["role_id"]).get_definition().get_raw() + + +@_register( + "create_role", + domain="roles", + kind="write", + description="Create a role: {label, description}.", + sdk="GovernAdminRolesPermissionsHandler.create_role", + params=( + NEW_IDENTIFIER, + _p("role", "object", "Role definition without an id.", True), + ), +) +def _create_role(client, p): + return ( + _roles(client) + .create_role(p["new_identifier"], p["role"]) + .get_definition() + .get_raw() + ) + + +@_register( + "update_role", + domain="roles", + kind="write", + description="Replace a role definition.", + sdk="GovernAdminRoleDefinition.save", + params=(ROLE_ID, _p("role", "object", "Complete role definition.", True)), +) +def _update_role(client, p): + role = _roles(client).get_role(p["role_id"]) + _replace_raw(role.get_definition(), p["role"]).save() + return role.get_definition().get_raw() + + +@_register( + "delete_role", + domain="roles", + kind="delete", + description="Delete a role.", + sdk="GovernAdminRole.delete", + params=(ROLE_ID,), +) +def _delete_role(client, p): + _roles(client).get_role(p["role_id"]).delete() + return {"deleted": p["role_id"]} + + +@_register( + "list_role_assignments", + domain="roles", + kind="read", + description="List the role assignments of every blueprint.", + sdk="GovernAdminRolesPermissionsHandler.list_role_assignments", +) +def _list_role_assignments(client, p): + return [item.get_raw() for item in _roles(client).list_role_assignments()] + + +@_register( + "get_role_assignments", + domain="roles", + kind="read", + description="Get the role assignments of a blueprint: roleAssignmentsRules keyed by role ID.", + sdk="GovernAdminBlueprintRoleAssignments.get_definition", + params=(BLUEPRINT_ID,), +) +def _get_role_assignments(client, p): + return ( + _roles(client) + .get_role_assignments(p["blueprint_id"]) + .get_definition() + .get_raw() + ) + + +@_register( + "create_role_assignments", + domain="roles", + kind="write", + description="Create the role assignments of a blueprint: {blueprintId, roleAssignmentsRules}.", + sdk="GovernAdminRolesPermissionsHandler.create_role_assignments", + params=( + _p( + "role_assignments", + "object", + "Role assignments including blueprintId.", + True, + ), + ), +) +def _create_role_assignments(client, p): + assignments = _roles(client).create_role_assignments(p["role_assignments"]) + return assignments.get_definition().get_raw() + + +@_register( + "update_role_assignments", + domain="roles", + kind="write", + description="Replace the role assignments of a blueprint; preserve the other roles' rules.", + sdk="GovernAdminBlueprintRoleAssignmentsDefinition.save", + params=( + BLUEPRINT_ID, + _p("role_assignments", "object", "Complete role assignments.", True), + ), +) +def _update_role_assignments(client, p): + assignments = _roles(client).get_role_assignments(p["blueprint_id"]) + _replace_raw(assignments.get_definition(), p["role_assignments"]).save() + return assignments.get_definition().get_raw() + + +@_register( + "delete_role_assignments", + domain="roles", + kind="delete", + description="Delete every role assignment of a blueprint.", + sdk="GovernAdminBlueprintRoleAssignments.delete", + params=(BLUEPRINT_ID,), +) +def _delete_role_assignments(client, p): + _roles(client).get_role_assignments(p["blueprint_id"]).delete() + return {"deleted": "role_assignments", "blueprint_id": p["blueprint_id"]} + + +@_register( + "get_default_blueprint_permissions", + domain="roles", + kind="read", + description="Get the default permissions applied to blueprints without their own.", + sdk="GovernAdminRolesPermissionsHandler.get_default_permissions_definition", +) +def _get_default_blueprint_permissions(client, p): + return _roles(client).get_default_permissions_definition().get_raw() + + +@_register( + "update_default_blueprint_permissions", + domain="roles", + kind="write", + description="Replace the default blueprint permissions.", + sdk="GovernAdminDefaultPermissionsDefinition.save", + params=(_p("permissions", "object", "Complete default permissions.", True),), +) +def _update_default_blueprint_permissions(client, p): + definition = _roles(client).get_default_permissions_definition() + _replace_raw(definition, p["permissions"]).save() + return _roles(client).get_default_permissions_definition().get_raw() + + +@_register( + "list_blueprint_permissions", + domain="roles", + kind="read", + description="List the blueprints that carry their own permissions.", + sdk="GovernAdminRolesPermissionsHandler.list_blueprint_permissions", +) +def _list_blueprint_permissions(client, p): + return [item.get_raw() for item in _roles(client).list_blueprint_permissions()] + + +@_register( + "get_blueprint_permissions", + domain="roles", + kind="read", + description="Get the permissions of a blueprint.", + sdk="GovernAdminBlueprintPermissions.get_definition", + params=(BLUEPRINT_ID,), +) +def _get_blueprint_permissions(client, p): + return ( + _roles(client) + .get_blueprint_permissions(p["blueprint_id"]) + .get_definition() + .get_raw() + ) + + +@_register( + "create_blueprint_permissions", + domain="roles", + kind="write", + description="Create the permissions of a blueprint: {blueprintId, permissions}.", + sdk="GovernAdminRolesPermissionsHandler.create_blueprint_permissions", + params=( + _p( + "blueprint_permissions", + "object", + "Permissions including blueprintId.", + True, + ), + ), +) +def _create_blueprint_permissions(client, p): + permissions = _roles(client).create_blueprint_permissions( + p["blueprint_permissions"] + ) + return permissions.get_definition().get_raw() + + +@_register( + "update_blueprint_permissions", + domain="roles", + kind="write", + description="Replace the permissions of a blueprint.", + sdk="GovernAdminBlueprintPermissionsDefinition.save", + params=( + BLUEPRINT_ID, + _p("blueprint_permissions", "object", "Complete permissions.", True), + ), +) +def _update_blueprint_permissions(client, p): + permissions = _roles(client).get_blueprint_permissions(p["blueprint_id"]) + _replace_raw(permissions.get_definition(), p["blueprint_permissions"]).save() + return permissions.get_definition().get_raw() + + +@_register( + "delete_blueprint_permissions", + domain="roles", + kind="delete", + description="Delete the permissions of a blueprint so the defaults apply again.", + sdk="GovernAdminBlueprintPermissions.delete", + params=(BLUEPRINT_ID,), +) +def _delete_blueprint_permissions(client, p): + _roles(client).get_blueprint_permissions(p["blueprint_id"]).delete() + return {"deleted": "blueprint_permissions", "blueprint_id": p["blueprint_id"]} + + +# --------------------------------------------------------------------------- +# custom pages +# --------------------------------------------------------------------------- + + +@_register( + "list_custom_pages", + domain="custom_pages", + kind="read", + description="List the custom pages visible to the authenticated user.", + sdk="GovernClient.list_custom_pages", +) +def _list_custom_pages(client, p): + return [item.get_raw() for item in client.list_custom_pages()] + + +@_register( + "get_custom_page", + domain="custom_pages", + kind="read", + description="Get a custom page as the authenticated user sees it.", + sdk="GovernCustomPage.get_definition", + params=(CUSTOM_PAGE_ID,), +) +def _get_custom_page(client, p): + return client.get_custom_page(p["custom_page_id"]).get_definition().get_raw() + + +@_register( + "get_custom_page_definition", + domain="custom_pages", + kind="read", + description="Get the editable definition of a custom page (designer).", + sdk="GovernAdminCustomPage.get_definition", + params=(CUSTOM_PAGE_ID,), +) +def _get_custom_page_definition(client, p): + return ( + _pages(client).get_custom_page(p["custom_page_id"]).get_definition().get_raw() + ) + + +@_register( + "create_custom_page", + domain="custom_pages", + kind="write", + description="Create a custom page; copy the definition shape from an existing page of the same type.", + sdk="GovernAdminCustomPagesHandler.create_custom_page", + params=( + NEW_IDENTIFIER, + _p("custom_page", "object", "Custom page definition without an id.", True), + ), +) +def _create_custom_page(client, p): + page = _pages(client).create_custom_page(p["new_identifier"], p["custom_page"]) + return page.get_definition().get_raw() + + +@_register( + "update_custom_page", + domain="custom_pages", + kind="write", + description="Replace a custom page definition.", + sdk="GovernAdminCustomPageDefinition.save", + params=( + CUSTOM_PAGE_ID, + _p("custom_page", "object", "Complete custom page definition.", True), + ), +) +def _update_custom_page(client, p): + page = _pages(client).get_custom_page(p["custom_page_id"]) + _replace_raw(page.get_definition(), p["custom_page"]).save() + return page.get_definition().get_raw() + + +@_register( + "delete_custom_page", + domain="custom_pages", + kind="delete", + description="Delete a custom page.", + sdk="GovernAdminCustomPage.delete", + params=(CUSTOM_PAGE_ID,), +) +def _delete_custom_page(client, p): + _pages(client).get_custom_page(p["custom_page_id"]).delete() + return {"deleted": p["custom_page_id"]} + + +@_register( + "get_custom_pages_order", + domain="custom_pages", + kind="read", + description="Get the display order of the custom pages as a list of IDs.", + sdk="GovernAdminCustomPagesHandler.get_custom_pages_order", +) +def _get_custom_pages_order(client, p): + return _pages(client).get_custom_pages_order() + + +@_register( + "update_custom_pages_order", + domain="custom_pages", + kind="write", + description="Set the display order of the custom pages; the list must contain every page ID.", + sdk="GovernAdminCustomPagesHandler.save_custom_pages_order", + params=(_p("order", "list", "Custom page IDs in display order.", True),), +) +def _update_custom_pages_order(client, p): + return _pages(client).save_custom_pages_order(list(p["order"])) + + +# --------------------------------------------------------------------------- +# time series +# --------------------------------------------------------------------------- + +DATAPOINTS = _p( + "datapoints", + "list", + "Datapoints, each {timestamp: epoch milliseconds, value: number}.", +) +MIN_TIMESTAMP = _p("min_timestamp", "integer", "Lower bound, epoch milliseconds.") +MAX_TIMESTAMP = _p("max_timestamp", "integer", "Upper bound, epoch milliseconds.") + + +@_register( + "create_time_series", + domain="time_series", + kind="write", + description="Create a time series, optionally with initial datapoints; returns its ID.", + sdk="GovernClient.create_time_series", + params=(DATAPOINTS,), +) +def _create_time_series(client, p): + datapoints = list(p.get("datapoints") or []) + time_series = client.create_time_series(datapoints=datapoints) + return {"time_series_id": time_series.time_series_id, "datapoints": len(datapoints)} + + +@_register( + "get_time_series_values", + domain="time_series", + kind="read", + description="Get the datapoints of a time series, optionally within a timestamp window.", + sdk="GovernTimeSeries.get_values", + params=(TIME_SERIES_ID, MIN_TIMESTAMP, MAX_TIMESTAMP), +) +def _get_time_series_values(client, p): + return client.get_time_series(p["time_series_id"]).get_values( + min_timestamp=p.get("min_timestamp"), max_timestamp=p.get("max_timestamp") + ) + + +@_register( + "push_time_series_values", + domain="time_series", + kind="write", + description="Push datapoints into a time series; upsert overwrites existing timestamps.", + sdk="GovernTimeSeries.push_values", + params=( + TIME_SERIES_ID, + _p("datapoints", "list", "Datapoints, each {timestamp, value}.", True), + _p("upsert", "boolean", "Overwrite existing timestamps (default true)."), + ), +) +def _push_time_series_values(client, p): + datapoints = list(p["datapoints"]) + client.get_time_series(p["time_series_id"]).push_values( + datapoints, upsert=bool(p.get("upsert", True)) + ) + return {"time_series_id": p["time_series_id"], "pushed": len(datapoints)} + + +@_register( + "delete_time_series_values", + domain="time_series", + kind="delete", + description="Delete datapoints of a time series within a window, or all of them without bounds.", + sdk="GovernTimeSeries.delete", + params=(TIME_SERIES_ID, MIN_TIMESTAMP, MAX_TIMESTAMP), +) +def _delete_time_series_values(client, p): + client.get_time_series(p["time_series_id"]).delete( + min_timestamp=p.get("min_timestamp"), max_timestamp=p.get("max_timestamp") + ) + return { + "time_series_id": p["time_series_id"], + "deleted": True, + "min_timestamp": p.get("min_timestamp"), + "max_timestamp": p.get("max_timestamp"), + } + + +# --------------------------------------------------------------------------- +# files +# --------------------------------------------------------------------------- + + +@_register( + "upload_file", + domain="files", + kind="write", + description="Upload a local file; attach the returned ID to an UPLOADED_FILE field afterwards.", + sdk="GovernClient.upload_file", + params=( + _p("file_path", "string", "Local path of the file to upload.", True), + _p( + "file_name", + "string", + "Name stored in Govern (default: the local file name).", + ), + ), +) +def _upload_file(client, p): + path = os.path.realpath( + os.path.expanduser(require_non_empty_string(p["file_path"], "file_path")) + ) + if not os.path.isfile(path): + raise FileNotFoundError(f"File not found: {path}") + file_name = p.get("file_name") or os.path.basename(path) + with open(path, "rb") as handle: + uploaded = client.upload_file(file_name, handle) + return uploaded.get_description() + + +@_register( + "get_uploaded_file", + domain="files", + kind="read", + description="Get the description (name, size, type) of an uploaded file.", + sdk="GovernUploadedFile.get_description", + params=(UPLOADED_FILE_ID,), +) +def _get_uploaded_file(client, p): + return client.get_uploaded_file(p["uploaded_file_id"]).get_description() + + +@_register( + "download_uploaded_file", + domain="files", + kind="read", + description="Download an uploaded file to a local path; never overwrites unless overwrite is true.", + sdk="GovernUploadedFile.download", + params=( + UPLOADED_FILE_ID, + _p("output_path", "string", "Local destination path.", True), + _p("overwrite", "boolean", "Replace an existing destination file."), + ), +) +def _download_uploaded_file(client, p): + output_path = require_non_empty_string(p["output_path"], "output_path") + overwrite = bool(p.get("overwrite", False)) + absolute_path = os.path.realpath(os.path.expanduser(output_path)) + parent = os.path.dirname(absolute_path) + if not os.path.isdir(parent): + raise FileNotFoundError(f"Destination directory does not exist: {parent}") + if os.path.lexists(absolute_path) and not overwrite: + raise FileExistsError( + f"Destination already exists: {absolute_path}. Set overwrite=true to replace it." + ) + uploaded = client.get_uploaded_file(p["uploaded_file_id"]) + description = uploaded.get_description() + stream = uploaded.download() + temporary_path = None + digest = hashlib.sha256() + size = 0 + try: + with tempfile.NamedTemporaryFile( + prefix=".govern-file-", suffix=".tmp", dir=parent, delete=False + ) as handle: + temporary_path = handle.name + while True: + chunk = stream.read(_CHUNK_SIZE) + if not chunk: + break + handle.write(chunk) + digest.update(chunk) + size += len(chunk) + if os.path.lexists(absolute_path) and not overwrite: + raise FileExistsError( + f"Destination already exists: {absolute_path}. Set overwrite=true to replace it." + ) + os.replace(temporary_path, absolute_path) + temporary_path = None + finally: + close = getattr(stream, "close", None) + if close is not None: + close() + if temporary_path is not None: + try: + os.unlink(temporary_path) + except FileNotFoundError: + pass + return { + "uploaded_file_id": p["uploaded_file_id"], + "output_path": absolute_path, + "size_bytes": size, + "sha256": digest.hexdigest(), + "description": description, + } + + +@_register( + "delete_uploaded_file", + domain="files", + kind="delete", + description="Delete an uploaded file.", + sdk="GovernUploadedFile.delete", + params=(UPLOADED_FILE_ID,), +) +def _delete_uploaded_file(client, p): + client.get_uploaded_file(p["uploaded_file_id"]).delete() + return {"deleted": p["uploaded_file_id"]} + + +# --------------------------------------------------------------------------- +# users and groups +# --------------------------------------------------------------------------- + + +@_register( + "list_users", + domain="users", + kind="read", + description="List Govern users; include_settings adds each user's full settings.", + sdk="GovernClient.list_users", + params=(_p("include_settings", "boolean", "Include full settings per user."),), +) +def _list_users(client, p): + return client.list_users( + as_objects=False, include_settings=bool(p.get("include_settings", False)) + ) + + +@_register( + "get_user", + domain="users", + kind="read", + description="Get a user's settings (admin).", + sdk="GovernUser.get_settings", + params=(LOGIN,), +) +def _get_user(client, p): + return client.get_user(p["login"]).get_settings().get_raw() + + +@_register( + "create_user", + domain="users", + kind="write", + description="Create a Govern user (admin).", + sdk="GovernClient.create_user", + params=( + LOGIN, + _p("password", "string", "Password; required for LOCAL users."), + _p("display_name", "string", "Display name."), + _p( + "source_type", + "string", + "LOCAL (default), LDAP, or another identity source.", + ), + _p("groups", "list", "Group names."), + _p("profile", "string", "User profile; leave empty for the server default."), + _p("email", "string", "Email address."), + ), +) +def _create_user(client, p): + kwargs: dict[str, Any] = {} + for key in ("display_name", "source_type", "groups", "profile", "email"): + if p.get(key) is not None: + kwargs[key] = p[key] + user = client.create_user(p["login"], p.get("password"), **kwargs) + return user.get_settings().get_raw() + + +@_register( + "update_user", + domain="users", + kind="write", + description="Merge top-level keys (displayName, email, groups, enabled, userProfile, ...) into a user's settings.", + sdk="GovernUserSettings.save", + params=(LOGIN, _p("settings", "object", "Settings keys to set.", True)), +) +def _update_user(client, p): + user = client.get_user(p["login"]) + settings = user.get_settings() + settings.get_raw().update(p["settings"]) + settings.save() + return user.get_settings().get_raw() + + +@_register( + "delete_user", + domain="users", + kind="delete", + description="Delete a Govern user (admin).", + sdk="GovernUser.delete", + params=( + LOGIN, + _p("allow_self_deletion", "boolean", "Allow deleting the authenticated user."), + ), +) +def _delete_user(client, p): + client.get_user(p["login"]).delete( + allow_self_deletion=bool(p.get("allow_self_deletion", False)) + ) + return {"deleted": p["login"]} + + +@_register( + "get_own_user", + domain="users", + kind="read", + description="Get the settings of the authenticated user.", + sdk="GovernOwnUser.get_settings", +) +def _get_own_user(client, p): + return client.get_own_user().get_settings().get_raw() + + +@_register( + "list_groups", + domain="users", + kind="read", + description="List Govern groups (admin).", + sdk="GovernClient.list_groups", +) +def _list_groups(client, p): + return client.list_groups() + + +@_register( + "get_group", + domain="users", + kind="read", + description="Get a group definition (admin).", + sdk="GovernGroup.get_definition", + params=(GROUP_NAME,), +) +def _get_group(client, p): + return client.get_group(p["name"]).get_definition() + + +@_register( + "create_group", + domain="users", + kind="write", + description="Create a Govern group (admin).", + sdk="GovernClient.create_group", + params=( + GROUP_NAME, + _p("description", "string", "Group description."), + _p("source_type", "string", "LOCAL (default) or LDAP."), + ), +) +def _create_group(client, p): + kwargs: dict[str, Any] = {} + for key in ("description", "source_type"): + if p.get(key) is not None: + kwargs[key] = p[key] + return client.create_group(p["name"], **kwargs).get_definition() + + +@_register( + "update_group", + domain="users", + kind="write", + description="Merge top-level keys (description, isGovernArchitect, ...) into a group definition.", + sdk="GovernGroup.set_definition", + params=(GROUP_NAME, _p("definition", "object", "Definition keys to set.", True)), +) +def _update_group(client, p): + group = client.get_group(p["name"]) + definition = group.get_definition() + definition.update(p["definition"]) + group.set_definition(definition) + return group.get_definition() + + +@_register( + "delete_group", + domain="users", + kind="delete", + description="Delete a Govern group (admin).", + sdk="GovernGroup.delete", + params=(GROUP_NAME,), +) +def _delete_group(client, p): + client.get_group(p["name"]).delete() + return {"deleted": p["name"]} + + +# --------------------------------------------------------------------------- +# instance +# --------------------------------------------------------------------------- + + +@_register( + "get_instance_info", + domain="instance", + kind="read", + description="Get the Govern node identity and version; confirms the URL targets a GOVERN node.", + sdk="GovernClient.get_instance_info", +) +def _get_instance_info(client, p): + return client.get_instance_info().raw + + +@_register( + "get_auth_info", + domain="instance", + kind="read", + description="Get the identity behind the configured API key.", + sdk="GovernClient.get_auth_info", +) +def _get_auth_info(client, p): + return client.get_auth_info() + + +# --------------------------------------------------------------------------- +# dispatcher +# --------------------------------------------------------------------------- + +_KIND_CHECKS: dict[str, Callable[[Any], bool]] = { + "string": lambda value: isinstance(value, str), + "boolean": lambda value: isinstance(value, bool), + "integer": lambda value: isinstance(value, int) and not isinstance(value, bool), + "object": lambda value: isinstance(value, dict), + "list": lambda value: isinstance(value, list), + "any": lambda value: True, +} + + +def _validate_params( + operation_id: str, operation: GovernOperation, params: dict[str, Any] +) -> None: + declared = {param.name: param for param in operation.params} + unexpected = sorted(set(params) - set(declared)) + if unexpected: + raise ValueError( + f"Unexpected params for '{operation_id}': {unexpected}. " + f"Allowed: {list(declared)}" + ) + missing = [ + param.name + for param in operation.params + if param.required and params.get(param.name) in (None, "") + ] + if missing: + raise ValueError(f"Missing required params for '{operation_id}': {missing}") + for name, value in params.items(): + if value is None: + continue + param = declared[name] + if not _KIND_CHECKS[param.kind](value): + raise ValueError(f"Param '{name}' must be a {param.kind}") + if param.kind == "string" and param.required: + require_non_empty_string(value, name) + + +def _operation_summary(operation_id: str, operation: GovernOperation) -> dict[str, Any]: + return { + "operation": operation_id, + "domain": operation.domain, + "kind": operation.kind, + "description": operation.description, + "sdk": operation.sdk, + "params": [ + { + "name": param.name, + "kind": param.kind, + "required": param.required, + "description": param.description, + } + for param in operation.params + ], + } + + +def _catalog(domain: str) -> str: + if domain and domain not in DOMAINS: + raise ValueError( + f"Unknown Govern domain '{domain}'. Available: {list(DOMAINS)}" + ) + operations = [ + _operation_summary(operation_id, operation) + for operation_id, operation in GOVERN_OPERATIONS.items() + if not domain or operation.domain == domain + ] + return compact_json( + {"count": len(operations), "domains": list(DOMAINS), "operations": operations} + ) + + +_verified_hosts: set[str] = set() + + +def _require_govern_node(client) -> None: + """Check once per host that the URL targets a GOVERN node. + + `/instance-info` is permission-gated, so an unreadable answer is not an + error; the node type is then trusted as configured. + """ + host = getattr(client, "host", "") + if host in _verified_hosts: + return + try: + node_type = client.get_instance_info().node_type + except Exception: + _verified_hosts.add(host) + return + if node_type != "GOVERN": + raise ValueError( + f"The configured Govern URL {host} points to a {node_type} node, not a " + "Govern node. Check DKU_GOVERN_URL or the Govern URL of the active instance." + ) + _verified_hosts.add(host) + + +@mcp.tool( + title="Govern", + description="Single entry point to Dataiku Govern. Leave operation empty to list the catalog; otherwise run one catalog operation through the Govern Python SDK.", + annotations={ + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": False, + "openWorldHint": False, + }, +) +async def govern( + ctx: Context, + operation: str = "", + params: dict[str, Any] | None = None, + domain: str = "", +) -> str: + """Run one Govern operation, or list the catalog when ``operation`` is empty. + + Args: + operation: Operation id from the catalog, for example ``get_artifact``. + Leave empty to get the catalog. + params: Flat JSON object with the operation's parameters: identifiers, + payloads and options, as listed in the catalog. + domain: Optional catalog filter: artifacts, signoffs, blueprints, roles, + custom_pages, time_series, files, users or instance. + """ + operation = (operation or "").strip() + domain = (domain or "").strip() + params = dict(params or {}) + if not operation: + if params: + raise ValueError("params are only valid with an operation") + return _catalog(domain) + if domain: + raise ValueError("domain is only valid when listing the catalog") + try: + spec = GOVERN_OPERATIONS[operation] + except KeyError: + raise ValueError( + f"Unknown Govern operation '{operation}'. Call govern with an empty " + "operation to list the catalog." + ) from None + _validate_params(operation, spec, params) + await ctx.info(f"Running Govern operation '{operation}'...") + + def _run(): + client = get_govern_client() + _require_govern_node(client) + return spec.run(client, params) + + return compact_json(await run_blocking(_run)) diff --git a/dataiku_mcp/tools/instances.py b/dataiku_mcp/tools/instances.py index 9c9e9d06..a128a557 100644 --- a/dataiku_mcp/tools/instances.py +++ b/dataiku_mcp/tools/instances.py @@ -60,10 +60,13 @@ async def list_instances(ctx: Context) -> str: "name": name, "url": inst.url, "description": inst.description, + "govern_url": inst.govern_url, "active": name == current_instance_name, } ) - return compact_json(columnar(result, ["name", "url", "description", "active"])) + return compact_json( + columnar(result, ["name", "url", "description", "govern_url", "active"]) + ) @mcp.tool( @@ -112,6 +115,11 @@ async def get_current_instance(ctx: Context) -> str: # Strip api_key from return value current_instance = asdict(get_current_instance_for_tool()) current_instance.pop("api_key", None) + govern_api_key = current_instance.pop("govern_api_key", "") + if current_instance.get("govern_url"): + current_instance["govern_api_key_configured"] = bool(govern_api_key) + else: + current_instance.pop("govern_no_check_certificate", None) current_instance["connection_status"] = "failed" try: client = get_dss_client() diff --git a/dataiku_mcp/tools/utils/auth.py b/dataiku_mcp/tools/utils/auth.py index 0b31d964..be88d6f6 100644 --- a/dataiku_mcp/tools/utils/auth.py +++ b/dataiku_mcp/tools/utils/auth.py @@ -81,6 +81,39 @@ def get_dss_client() -> dataikuapi.DSSClient: return client +def get_govern_client() -> dataikuapi.GovernClient: + """Get a Govern API client from the environment or the active instance. + + `DKU_GOVERN_URL` and `DKU_GOVERN_API_KEY` win when set. Otherwise the + active instance profile must carry a Govern URL and API key, entered + through `configure_instance`. + """ + connection = config.get_govern_connection_from_env() + if connection is None: + current_instance = get_current_instance_for_tool() + connection = config.govern_connection_for_instance(current_instance) + if not connection.url: + raise ValueError( + f"Dataiku instance '{current_instance.name}' has no Govern node " + "configured. Set DKU_GOVERN_URL and DKU_GOVERN_API_KEY in the MCP " + "server environment, or run configure_instance and fill the " + "Govern node fields." + ) + if not connection.api_key: + source = ( + "DKU_GOVERN_API_KEY" + if connection.source == "environment" + else f"the Govern API key of instance '{connection.instance_name}'" + ) + raise ValueError( + f"The Govern node at {connection.url} has no API key. Set {source}." + ) + + client = dataikuapi.GovernClient(connection.url, connection.api_key) + client._session.verify = not connection.no_check_certificate + return client + + async def require_admin() -> None: """Raise a concise error unless the configured credentials are an admin.""" diff --git a/docs/capabilities.md b/docs/capabilities.md index bde51785..91f49f7a 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -19,16 +19,21 @@ see the table below. Local profile actions are listed separately because they only change which Dataiku instance the local client targets. +**Dataiku Govern** runs on a separate node and is reached through the single `govern` +tool: a fixed catalog of 86 operations over the public Govern Python SDK, listed by +the tool itself when called without an operation. It counts once as a direct write +below; its reads and writes are separated inside the catalog. + ## Surface -**128 tools** · 93 read · 21 direct Dataiku write · 6 Cobuild · 4 execute · 3 local +**129 tools** · 93 read · 22 direct Dataiku write · 6 Cobuild · 4 execute · 3 local profile · 1 connection test | Bucket | # | Scope | |---|---|---| | Read / inspect | 93 | Never mutates | -| Direct Dataiku write | 21 | Bootstrap, project configuration, cross-project, admin | +| Direct Dataiku write | 22 | Bootstrap, project configuration, cross-project, admin, Govern | | Cobuild conversation | 6 | All flow and analytic building | | Execute | 4 | `build_datasets`, `run_recipe`, `run_scenario`, `abort_job` | | Local profile action | 3 | `configure_instance`, `switch_instance`, `delete_instance` | @@ -91,6 +96,7 @@ No Cobuild involved. Scope says what kind of access, and where a write lands. | Connections | `list_connections`, `get_connection_info`, `test_connection` | — | Read-only | | Instance settings | `list_container_exec_configs`, `list_spark_configs`, `get_licensing_status` | — | Read-only | | Data collections & sharing | `list_data_collections`, `list_data_collection_objects`, `list_shared_objects` | — | Read-only | +| Govern | `govern` (catalog and read operations) | `govern` (write and delete operations) | Separate Govern node; artifacts, signoffs, blueprints, roles, custom pages, time series, files, users | The **in-project** writes above configure or supply a project; they do not build its analytic logic. None of them build a recipe, a model, or an agent. @@ -101,7 +107,7 @@ Local client configuration, not Dataiku objects. | Tool | Effect | |---|---| -| `configure_instance` | Connect an instance; opens a local page for URL + API key | +| `configure_instance` | Connect an instance; opens a local page for URL + API key, with optional Govern node URL + API key | | `switch_instance` | Change which configured instance subsequent calls target | -| `list_instances`, `get_current_instance` | Show configured instances and the active one; `get_current_instance` includes the Dataiku version when available | +| `list_instances`, `get_current_instance` | Show configured instances and the active one, with their Govern URL when set; `get_current_instance` includes the Dataiku version when available | | `delete_instance` | Removes a **saved connection profile from the local config file**. Does not touch the Dataiku instance. | diff --git a/skills/dataiku-headless-setup/SKILL.md b/skills/dataiku-headless-setup/SKILL.md index c23bdd56..a050452b 100644 --- a/skills/dataiku-headless-setup/SKILL.md +++ b/skills/dataiku-headless-setup/SKILL.md @@ -21,7 +21,7 @@ Bring a new or broken plugin installation to a verified Dataiku connection. A re 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. -7. When the MCP tools are available, run `list_instances`. If no instance is configured, run `configure_instance` and have the user complete the local setup page. If multiple instances exist without an active one, ask which to use and run `switch_instance`. +7. When the MCP tools are available, run `list_instances`. If no instance is configured, run `configure_instance` and have the user complete the local setup page. The page has optional Govern node fields (URL and API key) for users who also work in Dataiku Govern; the `govern` tool needs them, or the `DKU_GOVERN_URL` and `DKU_GOVERN_API_KEY` environment variables. If multiple instances exist without an active one, ask which to use and run `switch_instance`. 8. Verify the active profile with `get_current_instance`. If its `connection_status` is `failed`, the discovered profile cannot connect to Dataiku; run `configure_instance` to replace or add a working profile instead of treating it as set up. When it is `connected`, make a lightweight read-only Dataiku call such as `list_projects` to validate the available Dataiku access. Never request or repeat the API key in chat. -Report completion with the uv version, active instance name and URL, Dataiku version when available, and whether the Dataiku read succeeded. If a restart is required, say that setup is incomplete and give the single next action. +Report completion with the uv version, active instance name and URL, Dataiku version when available, the Govern URL when configured, and whether the Dataiku read succeeded. If a restart is required, say that setup is incomplete and give the single next action. diff --git a/skills/dataiku-headless/SKILL.md b/skills/dataiku-headless/SKILL.md index 1f06f519..1aca5214 100644 --- a/skills/dataiku-headless/SKILL.md +++ b/skills/dataiku-headless/SKILL.md @@ -1,16 +1,18 @@ --- name: dataiku-headless -description: Use for Dataiku tasks including projects, flows, datasets, recipes, jobs, machine learning, agents, and Cobuild conversations. Choose the right reference guide, inspect state first, route project changes through Cobuild by default, and validate results after execution. +description: Use for Dataiku tasks including projects, flows, datasets, recipes, jobs, machine learning, agents, Cobuild conversations, and Govern. Choose the right reference guide, inspect state first, route project changes through Cobuild by default, use the govern tool for Govern, and validate results after execution. --- # Dataiku Headless Use this for any Dataiku task. Choose the right reference guide first, inspect the current state before acting, route project changes through Cobuild by default, and validate by re-reading the resulting state. +For **Dataiku Govern**, start with [Govern](./references/govern.md). Govern runs on its own node and is reached through the single `govern` tool, which needs a Govern URL and API key of its own. + ## Shared Operating Rules 1. If the user asks to install, set up, connect, or repair Dataiku Headless, or its MCP tools are unavailable just after installation, read `../dataiku-headless-setup/SKILL.md` and follow it before continuing. -2. Ensure an instance is configured before any Dataiku work. If `get_current_instance` errors or `list_instances` is empty, run `configure_instance` first. Keep the reported `dataiku_version` in context for version-sensitive requests. +2. Ensure an instance is configured before any Dataiku work. If `get_current_instance` errors or `list_instances` is empty, run `configure_instance` first. Keep the reported `dataiku_version` in context for version-sensitive requests. Govern work also needs the Govern node configured; its guide says how. 3. Discover project keys and object identifiers through tools; do not invent them. 4. Read before write. Inspect the current object, flow context, jobs, or run history before changing anything. 5. Treat the matching reference guide as the source of truth for object-specific concepts, inspection steps, and required references. @@ -36,6 +38,7 @@ Use this for any Dataiku task. Choose the right reference guide first, inspect t | User intent | Guide to read next | | --- | --- | +| Work with Dataiku Govern artifacts, blueprints, signoffs, roles, custom pages, users, files, or time series | `./references/govern.md` | | Discover projects, inspect project metadata or variables, orient in a flow, or create a new project | `./references/projects.md` | | Inspect or update project settings, including Flow display, pipelines, default code envs, or container execution | `./references/projects/settings.md` | | Inspect the instance project-folder hierarchy or organize projects into project folders | `./references/project-folders.md` | diff --git a/skills/dataiku-headless/references/govern.md b/skills/dataiku-headless/references/govern.md new file mode 100644 index 00000000..f935e1c9 --- /dev/null +++ b/skills/dataiku-headless/references/govern.md @@ -0,0 +1,149 @@ +--- +name: govern +description: Operate Dataiku Govern through the single govern tool, with task guides for artifacts, blueprints, signoffs, and supporting objects. +--- + +# Govern + +Govern runs on a separate node. Its records are artifacts described by blueprint +versions, with workflow steps and signoffs. A governed project is an artifact; +it is not a DSS project containing datasets and recipes. + +Use the `govern` tool for every Govern read and write. It wraps the public Govern +Python SDK behind a fixed catalog of 86 operations. Do not fall back to Python, +`dataikuapi`, or REST calls; if an operation is missing, report the gap. +DSS project work still uses the other MCP tools and Cobuild. + +## The tool + +`govern(operation, params, domain)`: + +- Empty `operation` returns the catalog: every operation with its domain, kind + (`read`, `write`, `delete`), parameters, and the SDK method it wraps. Set + `domain` to filter: `artifacts`, `signoffs`, `blueprints`, `roles`, + `custom_pages`, `time_series`, `files`, `users`, `instance`. +- `operation` plus `params` runs one operation. `params` is one flat JSON object + holding identifiers, payloads, and options exactly as the catalog names them. + Unknown keys and missing required keys are rejected before any call. +- Results are the SDK raw dictionaries: definitions, list items, or a small + status object for deletions. + +## Connect + +The Govern node needs its own URL and API key. The tool reads them from +`DKU_GOVERN_URL` and `DKU_GOVERN_API_KEY` in the MCP server environment +(`DKU_GOVERN_NO_CHECK_CERTIFICATE` is optional), and otherwise from the Govern +node fields of the active instance profile, entered through `configure_instance`. +Environment variables win. `get_current_instance` shows `govern_url` and whether a +Govern key is present; it never shows the key. + +Start a Govern task with `get_instance_info`: it confirms the node type is +`GOVERN` and reports the server version. Then `get_auth_info` tells which identity +signs the work. A global API key has no user behind it: `get_own_user` fails and +decisions are recorded under the key identity; a personal API key records them +under that user. If the tool reports a missing Govern configuration or a +permission error, report that specific blocker; do not repurpose DSS credentials. + +Blueprint design and custom page administration need the Govern Blueprint Designer +license option. Without it the server answers "Your license does not allow you to +use the Govern Blueprint Designer" for `create_blueprint`, version and signoff +configuration writes, and custom page writes and ordering. Report that as a +licensing blocker, not as a tool gap. + +## Workflow + +1. Call `govern` with an empty `operation` for the relevant `domain` when the + parameters of an operation are not in context. Never guess an operation id. +2. Discover identifiers through `list_blueprints`, `list_blueprint_versions`, + `search_artifacts`, `list_roles`, `list_custom_pages`, `list_users`, and + `list_groups`. Do not invent IDs. +3. Read the full target before changing it: `get_artifact`, + `get_blueprint_version`, `get_signoff`, `get_role`, `get_custom_page_definition`. +4. Run the write with the exact `operation` and `params`. Update operations + replace the complete definition unless the guide says merge. +5. Re-read after every write. The tool returns the persisted state, but compare it + with the intended change; a successful save does not prove the values held. +6. For multi-step work, verify each unit and retain the created IDs. After an + uncertain write, inspect before retrying a create. + +## Parameter rules + +- `update_artifact`, `update_blueprint`, `update_blueprint_version`, + `update_signoff_configuration`, `update_role`, `update_role_assignments`, + `update_blueprint_permissions`, `update_default_blueprint_permissions`, + `update_custom_page`, and `update_signoff_recurrence` replace the whole + definition. Send the full object read from the matching get operation with only + the requested keys changed. +- `update_artifact_fields`, `update_user`, and `update_group` merge the given + keys into the current definition and preserve the rest. +- `create_*` payloads never contain an `id`; the server derives it from + `new_identifier` or the parent. +- `users_container` values are `{"type": "user", "login": ...}`, + `{"type": "group", "groupName": ...}`, `{"type": "role", "roleId": ...}`, or + `{"type": "global-api-key", "keyId": ...}`. The type is lowercase. +- `update_signoff_status` notifies every configured reviewer or approver when + `users_to_notify` is omitted. Pass `[]` unless the task authorizes notifications. +- `update_blueprint_version` refuses a save that removes a field or changes its + type on a version with artifacts. `danger_zone_accepted: true` discards that + data everywhere; use it only after an explicit user decision. +- `search_artifacts` returns at most `max_results` hits (default 100) and + `has_more`. Raise `max_results` or narrow the filters for a complete inventory, + and say so when a result is a sample. + +## Operations + +| Domain | Operations | +| --- | --- | +| artifacts | `search_artifacts`, `get_artifact`, `create_artifact`, `update_artifact`, `update_artifact_fields`, `delete_artifact` | +| signoffs | `list_signoffs`, `get_signoff`, `get_signoff_details`, `create_signoff`, `update_signoff_status`, `list_signoff_feedbacks`, `get_signoff_feedback`, `add_signoff_feedback`, `update_signoff_feedback`, `delete_signoff_feedback`, `delegate_signoff_feedback`, `get_signoff_approval`, `add_signoff_approval`, `update_signoff_approval`, `delete_signoff_approval`, `delegate_signoff_approval`, `get_signoff_recurrence`, `update_signoff_recurrence` | +| blueprints | `list_blueprints`, `get_blueprint`, `list_blueprint_versions`, `get_blueprint_version`, `create_blueprint`, `update_blueprint`, `delete_blueprint`, `create_blueprint_version`, `update_blueprint_version`, `set_blueprint_version_status`, `delete_blueprint_version`, `list_signoff_configurations`, `get_signoff_configuration`, `create_signoff_configuration`, `update_signoff_configuration`, `delete_signoff_configuration` | +| roles | `list_roles`, `get_role`, `create_role`, `update_role`, `delete_role`, `list_role_assignments`, `get_role_assignments`, `create_role_assignments`, `update_role_assignments`, `delete_role_assignments`, `get_default_blueprint_permissions`, `update_default_blueprint_permissions`, `list_blueprint_permissions`, `get_blueprint_permissions`, `create_blueprint_permissions`, `update_blueprint_permissions`, `delete_blueprint_permissions` | +| custom_pages | `list_custom_pages`, `get_custom_page`, `get_custom_page_definition`, `create_custom_page`, `update_custom_page`, `delete_custom_page`, `get_custom_pages_order`, `update_custom_pages_order` | +| time_series | `create_time_series`, `get_time_series_values`, `push_time_series_values`, `delete_time_series_values` | +| files | `upload_file`, `get_uploaded_file`, `download_uploaded_file`, `delete_uploaded_file` | +| users | `list_users`, `get_user`, `create_user`, `update_user`, `delete_user`, `get_own_user`, `list_groups`, `get_group`, `create_group`, `update_group`, `delete_group` | +| instance | `get_instance_info`, `get_auth_info` | + +Blueprint version import, bulk user provisioning, API keys, licensing, logs, and +identity-provider settings are not in the catalog. Report them as gaps and hand off +to the Govern UI. + +## Choose the task guide + +| Task | Read next | +| --- | --- | +| Discover, create, or update governed records; inspect field schemas | [Artifacts](./govern/artifacts.md) | +| Design blueprint versions, fields, workflow, views, or hooks | [Blueprints](./govern/blueprints.md) | +| Configure review gates or work with feedback, approvals, and workflow state | [Signoffs](./govern/signoffs.md) | +| Manage roles, permissions, custom pages, users, groups, files, or time series | [Supporting objects](./govern/supporting-objects.md) | + +## Design the governance process + +For process design, identify the item being governed, its parent, required evidence, +responsible roles, and the decision that each review gate records. Reuse a suitable +standard blueprint before designing a custom item type. Derive risk criteria and +approval rules from the user's policy; a plausible template is not an established policy. + +Synced Dataiku assets and their governance layer are distinct. Discover an asset's +existing Govern record before creating another; a Govern project may also exist +before it is linked to a Dataiku project. A parent must be governed before its +children can be governed. Hiding a parent hides its children in Govern; it does +not delete them. See [Govern items](https://doc.dataiku.com/dss/latest/governance/types-govern-items.html) +and [governance actions](https://doc.dataiku.com/dss/latest/governance/governance-features.html). + +Workflow progress, signoff approval, and deployment authorization are separate. +Use the configured signoff to record a decision. For deployment eligibility, inspect +the [Deployer infrastructure policy](https://doc.dataiku.com/dss/latest/governance/deployment-policies.html); +an `approved` field or a finished workflow step is not a deployment gate. + +## Safety + +- Every `write` and `delete` operation is a mutation. Let the harness obtain user + confirmation before running one. +- Deleting a blueprint version requires deleting its artifacts first. Do not + cascade-delete artifacts to unblock a schema change. +- Decisions (`add_signoff_feedback`, `add_signoff_approval`) are recorded under the + authenticated identity. Do not alter reviewer membership to bypass a permission + error; delegation changes who reviews and is not impersonation. +- Keep local paths explicit for `upload_file` and `download_uploaded_file`. Never + expose API keys in payloads or responses. diff --git a/skills/dataiku-headless/references/govern/artifacts.md b/skills/dataiku-headless/references/govern/artifacts.md new file mode 100644 index 00000000..53122b4b --- /dev/null +++ b/skills/dataiku-headless/references/govern/artifacts.md @@ -0,0 +1,74 @@ +# Govern artifacts + +Use the `govern` tool described in [Govern](../govern.md). Identifiers such as +`blueprint_id`, `version_id`, and `artifact_id` come from discovery or the user. + +## Discover and inspect + +Blueprints and versions: + +- `list_blueprints` returns envelopes; the blueprint is nested under `blueprint`. +- `list_blueprint_versions` with `blueprint_id` lists versions and their status. +- `get_blueprint_version` with `blueprint_id` and `version_id` returns + `definition` (the schema, with `fieldDefinitions`) and `trace` (status, origin). + +Artifacts: + +- `search_artifacts` with `blueprint_ids`, `blueprint_version_ids`, `artifact_ids`, + `field_filters`, `archived`, and `sort`. Each hit nests the record under + `artifact`. The result carries `count` and `has_more`; when `has_more` is true, + raise `max_results` or narrow the filters before calling the inventory complete. + A field filter is `{"condition_type": "CONTAINS", "condition": "churn"}` for the + name, or adds `field_id` for one field. +- `get_artifact` with `artifact_id` returns the full definition: `name`, `fields`, + `workflow`, and `blueprintVersionId`. + +For an existing artifact, inspect its own blueprint version; another version of +the same blueprint can have different fields. + +Govern 15 also exposes creation and last-modification metadata, including the actor +and timestamp. Inspect `creationRevision` and `lastModificationRevision` when the +task needs provenance; preserve server-managed metadata when editing other fields. +These values describe creation and the latest change, not a complete audit history. + +## Create or update + +Create on an ACTIVE version after reading its `fieldDefinitions`: + +```json +{"operation": "create_artifact", + "params": {"blueprint_id": "bp.system.govern_project", "version_id": "bv.system.default", + "name": "Churn model", "fields": {"description": "Quarterly churn scoring"}}} +``` + +Retain the returned `id` and re-read with `get_artifact`. + +For a partial edit, use `update_artifact_fields` with `artifact_id` and `fields` +(and `name` when it changes). It merges the given fields and preserves the rest. +Compare the returned `fields` with the request; a field that did not persist means +the value was rejected silently, for example a scalar sent to a list field. + +For a structural edit (workflow state, archived flag, blueprint reference), use +`update_artifact` with the complete `definition` read from `get_artifact`, changed +only where requested. A partial definition loses unrelated state. + +`delete_artifact` removes the record; confirm the ID first. + +## Field rules + +- Fields are keyed by ID in `fieldDefinitions`; the type key is `fieldType`. + Use the actual field ID, not its display label. User-entered values belong in + `sourceType: STORE` fields; preserve computed fields. +- `listConfig` makes a field a list, even if the configuration is empty. Send an + array for one value as well. CATEGORY values must match the defined categories, + case-sensitively. +- REFERENCE values are artifact IDs, including references to user and group + artifacts; they are not logins or group names. Inspect `allowedBlueprints` + before selecting them. +- DATE values use an ISO-8601 datetime with timezone, for example + `2026-06-01T00:00:00.000Z`. Uploaded files and time series use IDs from + `upload_file` and `create_time_series`; see [Supporting objects](./supporting-objects.md). +- `required` is enforced on every artifact whatever the workflow step or the view. + +For workflow transitions or approvals, follow [Signoffs](./signoffs.md). An artifact +field edit is not a substitute for submitting feedback or approval. diff --git a/skills/dataiku-headless/references/govern/blueprints.md b/skills/dataiku-headless/references/govern/blueprints.md new file mode 100644 index 00000000..c6565d86 --- /dev/null +++ b/skills/dataiku-headless/references/govern/blueprints.md @@ -0,0 +1,99 @@ +# Govern blueprint authoring + +Use the `govern` tool described in [Govern](../govern.md). A blueprint stores identity +and presentation metadata. Its versions hold fields, workflow, views, hooks, and +actions. Blueprint design operations need blueprint designer rights on the API key. + +## Inspect, fork, edit, publish + +Inspect with `get_blueprint`, `list_blueprint_versions`, `get_blueprint_version` +(definition plus trace), and `list_signoff_configurations`. + +Prefer forking a suitable existing version when changing the structure, so the +required system fields and behavior survive: + +1. `create_blueprint_version` with `blueprint_id`, `new_identifier`, `name`, and + `origin_version_id`. The new version is DRAFT and invisible to users. +2. `get_blueprint_version` and edit the returned `definition` locally, preserving + everything not requested. +3. `update_blueprint_version` with the complete `definition`. Leave + `danger_zone_accepted` unset. +4. `create_signoff_configuration` per gated step; see [Signoffs](./signoffs.md). +5. `set_blueprint_version_status` to `ACTIVE` when activation is part of the task. + Check `trace.status` in the result. + +For a new blueprint, `create_blueprint` with `new_identifier` and a `blueprint` +body holding `name`, `icon`, `color`, and `backgroundColor`, without an `id`. Do +not assume a system version exists on a new blueprint or invent an origin ID. +`update_blueprint` replaces that metadata only. + +On a version used by artifacts, removing fields or changing types blocks the save. +A blocked save calls for a new version or an explicit decision covering that data +loss; do not retry with `danger_zone_accepted: true` on your own. Deleting a +version (`delete_blueprint_version`) requires removing its artifacts first; do not +cascade-delete them just to unblock a schema operation. + +## Definition structure + +| Key | Purpose and checks | +| --- | --- | +| `id` | Preserve the `{blueprintId, versionId}` of the target version. | +| `fieldDefinitions` | Dictionary keyed by field ID; inspect `fieldType`, `sourceType`, and list or required constraints. See [Artifacts](./artifacts.md). | +| `workflowDefinition` | Contains ordered `stepDefinitions`; preserve stable step IDs referenced by signoffs and UI definitions. | +| `uiDefinition` | Layout structure varies by release: inspect `views`, `uiStepDefinitions`, and the artifact page or tab configuration. Workflow and UI step IDs must match. | +| `logicalHookList` | Pre-phase hooks for validation and computed fields before commit. | +| `postLogicalHookList` | Govern 15 post-phase hooks for work after commit. Preserve both hook lists when editing unrelated settings. | +| `actions` | Action definitions keyed by action ID; a UI action component must reference the action for it to be visible. | + +An accepted definition can still render an empty artifact page. Check that the +configured artifact tabs or page and workflow steps resolve to nonempty views, and +that editable fields appear in the intended views. Older definitions may use +`artifactPageViewId`; Govern 15 definitions can use `tabs` and +`artifactStructureTabIds` instead. The optional `customRightPanel` and +`rightPanelTabIds` configure the selection panel independently. A custom tab can +be shared by the artifact page and right panel; its ID and view remain shared. +System tabs such as Workflow, Timeline, and Role assignments belong only on the +artifact page. Preserve stable tab IDs used in navigation, and check both orders +and separators. See [page structure](https://doc.dataiku.com/dss/latest/governance/blueprint-designer/blueprint-version-design.html#design-the-item-page-structure). + +Reuse components from an inspected definition rather than inventing UI keys. +Govern 15 text components have separate display settings (Markdown or plain text) +and editor settings (rich text, single-line, or multiline); configure these on the +view component without changing the field's `TEXT` type. State separately whether +layout was inspected in the UI or only checked structurally. + +Required field constraints apply globally; hiding a required field does not make +it optional. A hidden workflow step can bypass its mandatory signoff. Review view +and step visibility conditions when they affect the requested approval behavior. + +Field and workflow saves alone do not create review gates; use the signoff +configuration operations in [Signoffs](./signoffs.md). + +## Choose the hook phase + +Hooks select lifecycle events (`CREATE`, `UPDATE`, `DELETE`) and when to run: + +- Pre-phase hooks run before commit. Use them to validate changes or populate + computed fields. The operation can still fail afterward; keep external side + effects and API writes to other items out of these hooks. To schedule related + items' UPDATE hooks after commit, use `handler.artifactIdsToUpdate`. +- Govern 15 post-phase hooks run after commit. Use them for work that requires + persisted state, such as an authorized notification or external synchronization. + They cannot validate or veto an action that has already committed. Check the + saved item and any downstream effect separately before retrying. + +Use the target release's [hook documentation and editor samples](https://doc.dataiku.com/dss/latest/governance/blueprint-designer/blueprint-version-design.html#set-rules-with-hooks) +for the handler context. Older examples may emulate post-create work with threads +and polling; use native post-phase hooks on Govern 15 when that is the intent. +Treat scripts as executable changes, and verify execution as well as saved configuration. + +## Export and import boundary + +A local JSON snapshot can hold the full version definition, trace, and signoff +configurations read above. Keep it outside the plugin and protect sensitive content. +A definition alone is not a complete transferable blueprint package. + +The catalog has no blueprint version import operation, because the public SDK has +none. For a cross-instance import or artifact-version migration, report the missing +operation and use the Govern UI as the handoff. Do not claim that a JSON snapshot +supplies import parity. diff --git a/skills/dataiku-headless/references/govern/signoffs.md b/skills/dataiku-headless/references/govern/signoffs.md new file mode 100644 index 00000000..c63cfb80 --- /dev/null +++ b/skills/dataiku-headless/references/govern/signoffs.md @@ -0,0 +1,91 @@ +# Govern workflow and signoffs + +Use the `govern` tool described in [Govern](../govern.md). A blueprint version's +signoff configuration defines a review gate. An artifact's signoff is the runtime +review, including feedback and approval. Inspect both before changing either. + +## Workflow state + +Read `get_artifact` and its blueprint version. On Govern 13.5+, use +`workflow.steps[step_id]` for state and visibility; order comes from the version's +`workflowDefinition.stepDefinitions`. `status.stepId` is deprecated and may name a +step that is not actually ongoing. See the +[official workflow reference](https://developer.dataiku.com/latest/concepts-and-examples/govern/govern-artifacts/govern-artifact-workflow.html). + +When asked to advance the workflow, read the current steps and required signoffs, +change only the intended step states in the full definition, save with +`update_artifact`, and re-read. Preserve visibility and unrelated steps. Do not skip +a required review or rewrite the legacy status field to make signoff creation succeed. + +## Configure a review gate + +- `list_signoff_configurations` with `blueprint_id` and `version_id` lists gates. +- `get_signoff_configuration` with `step_id` returns one. +- `create_signoff_configuration` with `step_id` and a `configuration` without a + top-level `id`; the server derives it from the step and version. +- `update_signoff_configuration` replaces the complete `configuration`. +- `delete_signoff_configuration` removes the gate. + +Configuration keys: + +- `title`, `mandatory`, `feedbackUsersGroups`, `approvers`, and + `recurrenceConfiguration`. Preserve unrelated settings when updating. +- Each feedback group has an `id`, a non-empty `title`, and a `users` list. The + group's ID is used by `add_signoff_feedback` and `delegate_signoff_feedback`; + it is not necessarily a Govern role ID. +- Reviewers and approvers are `{"usersContainer": {...}}` entries. The container + type is lowercase: `user` (`login`), `group` (`groupName`), `role` (`roleId`), + or `global-api-key` (`keyId`). Discover the user, group, or role first. + Approvers form a flat list. +- `recurrenceConfiguration` is `{activated, days, weeks, months, years, reloadConf}`; + activation needs a positive interval. + +Example: + +```json +{"title": "Review gate", "mandatory": true, + "feedbackUsersGroups": [{"id": "reviewers", "title": "Reviewers", + "users": [{"usersContainer": {"type": "user", "login": "alice"}}]}], + "approvers": [{"usersContainer": {"type": "user", "login": "bob"}}], + "recurrenceConfiguration": {"activated": false, "days": 0, "weeks": 0, "months": 0, "years": 0, "reloadConf": false}} +``` + +Re-read the configuration and check reviewers, approvers, `mandatory`, and the step. +Existing runtime signoffs keep their previous configuration until reset; a +configuration edit alone does not update every review. + +## Run a signoff + +- `list_signoffs` with `artifact_id` lists the runtime signoffs. +- `get_signoff` returns status, feedback responses, and the approver response. +- `get_signoff_details` resolves reviewer and approver membership to users. +- `create_signoff` creates the review for a step, only when the step is ONGOING and + a configuration exists. Signoffs may already exist from artifact creation. + +| Intent | Operation | +| --- | --- | +| Start feedback or approval collection | `update_signoff_status` with `status: WAITING_FOR_FEEDBACK` or `WAITING_FOR_APPROVAL` and `users_to_notify: []` | +| Read feedback | `list_signoff_feedbacks`, `get_signoff_feedback` | +| Record requested feedback | `add_signoff_feedback` with `group_id`, `status`, `comment` | +| Change or remove a feedback | `update_signoff_feedback`, `delete_signoff_feedback` | +| Read approval | `get_signoff_approval` | +| Record requested approval decision | `add_signoff_approval` with `status`, `comment` | +| Change or remove the approval | `update_signoff_approval`, `delete_signoff_approval` | +| Delegate reviewers | `delegate_signoff_feedback` with `group_id` and `users_container`, or `delegate_signoff_approval` with `users_container` | +| Scheduled reset | `get_signoff_recurrence`, `update_signoff_recurrence` | + +Feedback statuses are `APPROVED`, `MINOR_ISSUE`, and `MAJOR_ISSUE`; approval statuses +are `APPROVED`, `REJECTED`, and `ABANDONED`. Signoff statuses move +`NOT_STARTED → WAITING_FOR_FEEDBACK → WAITING_FOR_APPROVAL → APPROVED | REJECTED | ABANDONED`. +Submit decisions only when requested, under the authenticated identity. +Administrative API access does not make that identity a reviewer or approver; check +the configured membership, and do not alter it to bypass a permission error. +Delegation changes who reviews and is not impersonation. + +Omitting `users_to_notify` from `update_signoff_status` notifies every configured +reviewer or approver of the target stage. Pass `[]` unless the task authorizes +notifications; when notifying, list `{userLogin, groupId}` entries for the feedback +stage and `{userLogin}` entries for the approval stage. Resetting an in-progress +review requires `ABANDONED` before `NOT_STARTED`; setting `APPROVED` through +`update_signoff_status` is not a substitute for recording an approval. +`reload_conf_for_reset: true` also clears delegations; do not set it incidentally. diff --git a/skills/dataiku-headless/references/govern/supporting-objects.md b/skills/dataiku-headless/references/govern/supporting-objects.md new file mode 100644 index 00000000..d5b79484 --- /dev/null +++ b/skills/dataiku-headless/references/govern/supporting-objects.md @@ -0,0 +1,91 @@ +# Govern supporting objects + +Use the `govern` tool described in [Govern](../govern.md). These operations use the +Govern credentials and permissions. The identically named DSS user and group MCP +tools target DSS and must not be used here. + +## Roles, permissions, and identities + +| Task | Operations | +| --- | --- | +| Roles | `list_roles`, `get_role`, `create_role`, `update_role`, `delete_role` | +| Role assignments per blueprint | `list_role_assignments`, `get_role_assignments`, `create_role_assignments`, `update_role_assignments`, `delete_role_assignments` | +| Blueprint permissions | `get_default_blueprint_permissions`, `update_default_blueprint_permissions`, `list_blueprint_permissions`, `get_blueprint_permissions`, `create_blueprint_permissions`, `update_blueprint_permissions`, `delete_blueprint_permissions` | +| Users | `list_users`, `get_user`, `create_user`, `update_user`, `delete_user`, `get_own_user` | +| Groups | `list_groups`, `get_group`, `create_group`, `update_group`, `delete_group` | + +Read the existing definition before saving and re-read afterward. Role definitions +and blueprint role assignments are separate resources. Updating one role's binding +must preserve the other roles; `delete_role_assignments` removes every binding of +the blueprint. For global permissions on Govern 15, use `isGovernArchitect`; the +deprecated `mayManageGovern` key was removed. Discover existing permissions before +changing them. + +Role bindings live in `roleAssignmentsRules[role_id]`, a list of rules with +`criteria`, `userContainers`, and `fieldIds`. An unconditional binding has empty +`criteria` and `fieldIds` lists and the selected user or group containers in +`userContainers`. Inspect a matching rule and verify the saved membership; change +one role's rules inside the full assignment definition, never replace the whole +definition to change one role. + +`update_user` and `update_group` merge the given keys into the current settings or +definition. `create_user` needs a `password` for LOCAL users; other keys are optional. + +## Custom pages + +- `list_custom_pages` and `get_custom_page` show pages as the authenticated user + sees them. +- `get_custom_page_definition`, `create_custom_page`, `update_custom_page`, + `delete_custom_page`, `get_custom_pages_order`, and `update_custom_pages_order` + are designer operations. + +Custom pages can display artifact views or custom HTML. Discover the target +release's page types from an existing page; built-in `standard-page` entries are not +creatable custom pages. Preserve the page type and definition, and treat HTML and +scripts as executable content. An external embed needs a browser-accessible URL and +compatible authentication; a saved definition does not prove that the embedded page +renders. + +### Govern 15 grid pages and charts + +Prefer native grid pages for dashboards over Govern items. A `grid` page can contain +multiple tabs with table, chart, HTML, and nested subgrid tiles. Copy the payload +structure from a matching page read with `get_custom_page_definition` instead of +guessing nested keys. + +Build the chart's data settings before its appearance: + +1. Filter the intended items and add projections for the needed fields. By default, + only item IDs are projected. Table tiles fetch their own columns, so a working + table does not prove that a chart has the data it needs. +2. Define breakdown dimensions and measures, then select that breakdown and measure + in the chart. A subgrid inherits its parent's data settings unless overridden. +3. Use standard charts when they fit. Chart selections filter tables and charts + sharing the same data settings. Converting to a custom JavaScript or ECharts + chart cannot be reverted to the standard chart configuration. + +Re-read the saved definition, then check the page with real data: displayed counts +and measures, drill-down targets, and selection-panel behavior. A preview using +sample data does not validate the real query. Create pages hidden while validating +them, and make them visible when publication is part of the task. See +[custom page design](https://doc.dataiku.com/dss/latest/governance/custom-pages.html) +for the current tile, projection, aggregation, and chart options. + +## Files and time series + +- `upload_file` with a local `file_path` returns the file description; retain its + `id`. `get_uploaded_file` reads the description, `download_uploaded_file` writes + the content to an explicit `output_path` and never overwrites unless `overwrite` + is true, `delete_uploaded_file` removes it. +- Attach the returned file ID to the appropriate artifact field through + `update_artifact_fields`, then re-read the field. Uploading alone does not attach it. +- `create_time_series` returns `time_series_id`; retain it. Use + `get_time_series_values`, `push_time_series_values`, and + `delete_time_series_values` afterward. +- Datapoints contain `timestamp` in epoch **milliseconds** and `value`. + `push_time_series_values` with `upsert: true` (the default) overwrites existing + timestamps; choose the mode deliberately. Read the affected interval after a + write. `delete_time_series_values` removes values in its timestamp window, or all + values when neither bound is supplied; it does not delete the time-series object. + Verify boundary behavior before a range deletion: Govern 15.0.1 excludes both + endpoint timestamps from reads and deletes. diff --git a/tests/test_tool_surface.py b/tests/test_tool_surface.py index fdabff35..0a1faede 100644 --- a/tests/test_tool_surface.py +++ b/tests/test_tool_surface.py @@ -101,6 +101,7 @@ "general_settings": frozenset( {"list_container_exec_configs", "list_spark_configs"} ), + "govern": frozenset({"govern"}), "groups": frozenset( {"create_group", "delete_group", "list_groups", "update_group"} ), From 03ae32fdf155795c0de121608ddd4738b9b374a6 Mon Sep 17 00:00:00 2001 From: Pierre Portal Date: Tue, 15 Sep 2026 18:46:42 +0200 Subject: [PATCH 2/9] fix(govern): match delegation and users-container contracts verified live --- dataiku_mcp/tools/govern.py | 35 ++++++++----------- skills/dataiku-headless/references/govern.md | 8 +++-- .../references/govern/signoffs.md | 14 +++++--- 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/dataiku_mcp/tools/govern.py b/dataiku_mcp/tools/govern.py index b73d8bf4..fce73e71 100644 --- a/dataiku_mcp/tools/govern.py +++ b/dataiku_mcp/tools/govern.py @@ -139,29 +139,24 @@ def _p(name: str, kind: str, description: str, required: bool = False) -> Govern USERS_CONTAINER = _p( "users_container", "object", - "Users container: {type: user|group|role|global-api-key, login|groupName|roleId|keyId}.", + "Delegate: {type: user, login}. Delegation accepts a single user only.", True, ) COMMENT = _p("comment", "string", "Optional comment recorded with the decision.") -class _UsersContainer: - """Adapter so a raw users-container dict satisfies the SDK build() contract.""" - - def __init__(self, definition: dict[str, Any]): - self.definition = definition - - def build(self) -> dict[str, Any]: - return self.definition - - -def _users_container(value: Any) -> _UsersContainer: - if not isinstance(value, dict) or not value.get("type"): +def _users_container(value: Any) -> dict[str, Any]: + """Validate a raw users-container dict; the SDK sends it as the request body.""" + if ( + not isinstance(value, dict) + or value.get("type") != "user" + or not value.get("login") + ): raise ValueError( - "users_container must be an object with a 'type' key: " - "user, group, role or global-api-key" + "users_container must be {type: 'user', login: }; delegation " + "accepts a single user only" ) - return _UsersContainer(value) + return value def _replace_raw(definition, new_raw: dict[str, Any]): @@ -590,7 +585,7 @@ def _update_signoff_feedback(client, p): "delete_signoff_feedback", domain="signoffs", kind="delete", - description="Delete a feedback response.", + description="Delete a feedback response; the signoff must be WAITING_FOR_FEEDBACK.", sdk="GovernArtifactSignoffFeedback.delete", params=(ARTIFACT_ID, STEP_ID, FEEDBACK_ID), ) @@ -603,7 +598,7 @@ def _delete_signoff_feedback(client, p): "delegate_signoff_feedback", domain="signoffs", kind="write", - description="Add a delegated reviewer to a feedback group of a signoff.", + description="Add a delegated reviewer (one user) to a feedback group of a signoff.", sdk="GovernArtifactSignoff.delegate_feedback", params=( ARTIFACT_ID, @@ -666,7 +661,7 @@ def _update_signoff_approval(client, p): "delete_signoff_approval", domain="signoffs", kind="delete", - description="Delete the recorded approval of a signoff.", + description="Delete the recorded approval; the signoff must be APPROVED, REJECTED or ABANDONED.", sdk="GovernArtifactSignoffApproval.delete", params=(ARTIFACT_ID, STEP_ID), ) @@ -683,7 +678,7 @@ def _delete_signoff_approval(client, p): "delegate_signoff_approval", domain="signoffs", kind="write", - description="Add a delegated approver to a signoff.", + description="Add a delegated approver (one user) to a signoff.", sdk="GovernArtifactSignoff.delegate_approval", params=(ARTIFACT_ID, STEP_ID, USERS_CONTAINER), ) diff --git a/skills/dataiku-headless/references/govern.md b/skills/dataiku-headless/references/govern.md index f935e1c9..f93f10ad 100644 --- a/skills/dataiku-headless/references/govern.md +++ b/skills/dataiku-headless/references/govern.md @@ -78,9 +78,11 @@ licensing blocker, not as a tool gap. keys into the current definition and preserve the rest. - `create_*` payloads never contain an `id`; the server derives it from `new_identifier` or the parent. -- `users_container` values are `{"type": "user", "login": ...}`, - `{"type": "group", "groupName": ...}`, `{"type": "role", "roleId": ...}`, or - `{"type": "global-api-key", "keyId": ...}`. The type is lowercase. +- Users containers in signoff configurations and role rules are + `{"type": "user", "login": ...}`, `{"type": "group", "groupName": ...}`, + `{"type": "role", "roleId": ...}`, or `{"type": "global-api-key", "globalAPIKeyId": ...}`. + The type is lowercase. The `users_container` param of the two delegation + operations accepts the `user` form only. - `update_signoff_status` notifies every configured reviewer or approver when `users_to_notify` is omitted. Pass `[]` unless the task authorizes notifications. - `update_blueprint_version` refuses a save that removes a field or changes its diff --git a/skills/dataiku-headless/references/govern/signoffs.md b/skills/dataiku-headless/references/govern/signoffs.md index c63cfb80..3fb1b1fa 100644 --- a/skills/dataiku-headless/references/govern/signoffs.md +++ b/skills/dataiku-headless/references/govern/signoffs.md @@ -35,8 +35,8 @@ Configuration keys: it is not necessarily a Govern role ID. - Reviewers and approvers are `{"usersContainer": {...}}` entries. The container type is lowercase: `user` (`login`), `group` (`groupName`), `role` (`roleId`), - or `global-api-key` (`keyId`). Discover the user, group, or role first. - Approvers form a flat list. + or `global-api-key` (`globalAPIKeyId`). Discover the user, group, or role + first. Approvers form a flat list. - `recurrenceConfiguration` is `{activated, days, weeks, months, years, reloadConf}`; activation needs a positive interval. @@ -59,8 +59,10 @@ configuration edit alone does not update every review. - `list_signoffs` with `artifact_id` lists the runtime signoffs. - `get_signoff` returns status, feedback responses, and the approver response. - `get_signoff_details` resolves reviewer and approver membership to users. -- `create_signoff` creates the review for a step, only when the step is ONGOING and - a configuration exists. Signoffs may already exist from artifact creation. +- A signoff exists only for an ONGOING step. Setting `workflow.steps..status` + to `ONGOING` through `update_artifact` creates the signoff when the step has a + configuration; `create_signoff` covers the case where it is missing. Both fail + on a step that is not ONGOING. | Intent | Operation | | --- | --- | @@ -71,7 +73,7 @@ configuration edit alone does not update every review. | Read approval | `get_signoff_approval` | | Record requested approval decision | `add_signoff_approval` with `status`, `comment` | | Change or remove the approval | `update_signoff_approval`, `delete_signoff_approval` | -| Delegate reviewers | `delegate_signoff_feedback` with `group_id` and `users_container`, or `delegate_signoff_approval` with `users_container` | +| Delegate reviewers | `delegate_signoff_feedback` with `group_id` and `users_container`, or `delegate_signoff_approval` with `users_container`; both take one `{"type": "user", "login": ...}` | | Scheduled reset | `get_signoff_recurrence`, `update_signoff_recurrence` | Feedback statuses are `APPROVED`, `MINOR_ISSUE`, and `MAJOR_ISSUE`; approval statuses @@ -89,3 +91,5 @@ stage and `{userLogin}` entries for the approval stage. Resetting an in-progress review requires `ABANDONED` before `NOT_STARTED`; setting `APPROVED` through `update_signoff_status` is not a substitute for recording an approval. `reload_conf_for_reset: true` also clears delegations; do not set it incidentally. +A feedback can be deleted only while the signoff is `WAITING_FOR_FEEDBACK`, and +the approval only once the signoff is `APPROVED`, `REJECTED`, or `ABANDONED`. From 8f53862970dcc6b30d7539acc02fdb6acc48436a Mon Sep 17 00:00:00 2001 From: Pierre Portal Date: Thu, 1 Oct 2026 19:07:14 +0200 Subject: [PATCH 3/9] fix(govern): list the govern tool in capabilities and state why HTTP mode is refused --- dataiku_mcp/auth.py | 5 +++-- docs/capabilities.md | 5 +++-- skills/dataiku-headless/references/govern.md | 4 ++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/dataiku_mcp/auth.py b/dataiku_mcp/auth.py index afc83620..f553562c 100644 --- a/dataiku_mcp/auth.py +++ b/dataiku_mcp/auth.py @@ -51,8 +51,9 @@ def get_govern_client() -> dataikuapi.GovernClient: """ if request.is_http_request(): raise ValueError( - "The govern tool is available only in local stdio mode. HTTP mode " - "has no per-user Govern credentials." + "The govern tool is available only in local stdio mode. A Govern " + "node accepts only API keys, not the delegated tokens that HTTP mode " + "uses." ) connection = stdio.get_govern_connection_from_env() if connection is None: diff --git a/docs/capabilities.md b/docs/capabilities.md index 3c389167..504cad0e 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -24,13 +24,13 @@ instance the local client targets. ## Surface -**129 tools** · 93 read · 22 direct Dataiku write · 6 Cobuild · 4 execute · 3 local +**130 tools** · 93 read · 23 direct Dataiku write · 6 Cobuild · 4 execute · 3 local profile · 1 connection test | Bucket | # | Scope | |---|---|---| | Read / inspect | 93 | Never mutates | -| Direct Dataiku write | 22 | Bootstrap, project configuration, cross-project, admin | +| Direct Dataiku write | 23 | Bootstrap, project configuration, cross-project, admin, Govern | | Cobuild conversation | 6 | All flow and analytic building | | Execute | 4 | `build_datasets`, `run_recipe`, `run_scenario`, `abort_job` | | Local profile action | 3 | `configure_instance`, `switch_instance`, `delete_instance` | @@ -95,6 +95,7 @@ No Cobuild involved. Scope says what kind of access, and where a write lands. | Connections | `list_connections`, `get_connection_info`, `test_connection` | — | Read-only | | Instance settings | `list_container_exec_configs`, `list_spark_configs`, `get_licensing_status` | — | Read-only | | Data collections & sharing | `list_data_collections`, `list_data_collection_objects`, `list_shared_objects` | — | Read-only | +| Govern | `govern` (catalog and read operations) | `govern` (write and delete operations) | Separate Govern node — stdio only | The **in-project** writes above configure or supply a project; they do not build its analytic logic. None of them build recipe logic, a model, or an agent. diff --git a/skills/dataiku-headless/references/govern.md b/skills/dataiku-headless/references/govern.md index f93f10ad..abb9e7a2 100644 --- a/skills/dataiku-headless/references/govern.md +++ b/skills/dataiku-headless/references/govern.md @@ -37,6 +37,10 @@ node fields of the active instance profile, entered through `configure_instance` Environment variables win. `get_current_instance` shows `govern_url` and whether a Govern key is present; it never shows the key. +The tool works only in local stdio mode. A Govern node accepts API keys, not the +delegated tokens that the HTTP mode uses, so in HTTP mode the tool returns an +error; report it and do not look for another way in. + Start a Govern task with `get_instance_info`: it confirms the node type is `GOVERN` and reports the server version. Then `get_auth_info` tells which identity signs the work. A global API key has no user behind it: `get_own_user` fails and From a40c4d4c6872d5cc68eb6c85b991d2e0ff6a1f34 Mon Sep 17 00:00:00 2001 From: Pierre Portal Date: Thu, 1 Oct 2026 19:14:19 +0200 Subject: [PATCH 4/9] feat(govern): run HTTP-mode calls as the caller through a Govern admin key --- dataiku_mcp/auth.py | 32 +++++++++++++++----- docs/capabilities.md | 2 +- skills/dataiku-headless/references/govern.md | 7 +++-- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/dataiku_mcp/auth.py b/dataiku_mcp/auth.py index f553562c..99caa35d 100644 --- a/dataiku_mcp/auth.py +++ b/dataiku_mcp/auth.py @@ -48,15 +48,29 @@ def get_govern_client() -> dataikuapi.GovernClient: `DKU_GOVERN_URL` and `DKU_GOVERN_API_KEY` win when set. Otherwise the active instance profile must carry a Govern URL and API key, entered through `configure_instance`. + + Govern accepts only API keys, not delegated tokens. In HTTP mode the + environment key must be a Govern admin key: each call runs as the caller's + Dataiku login through the proxy-user header, so Govern applies that + user's permissions. """ - if request.is_http_request(): - raise ValueError( - "The govern tool is available only in local stdio mode. A Govern " - "node accepts only API keys, not the delegated tokens that HTTP mode " - "uses." - ) connection = stdio.get_govern_connection_from_env() - if connection is None: + extra_headers = None + if request.is_http_request(): + if connection is None: + raise ValueError( + "No Govern node is configured for HTTP mode. An administrator must " + "set DKU_GOVERN_URL and DKU_GOVERN_API_KEY (a Govern admin key) in " + "the MCP server environment." + ) + login = get_dss_client().get_auth_info().get("associatedDSSUser") + if not login: + raise PermissionError( + "The Dataiku token is not tied to a Dataiku user, so the Govern " + "call cannot run as a user." + ) + extra_headers = {"X-DKU-ProxyUser": login} + elif connection is None: current_instance = request.get_pinned_instance() connection = stdio.govern_connection_for_instance(current_instance) if not connection.url: @@ -76,7 +90,9 @@ def get_govern_client() -> dataikuapi.GovernClient: f"The Govern node at {connection.url} has no API key. Set {source}." ) - client = dataikuapi.GovernClient(connection.url, connection.api_key) + client = dataikuapi.GovernClient( + connection.url, connection.api_key, extra_headers=extra_headers + ) client._session.verify = not connection.no_check_certificate return client diff --git a/docs/capabilities.md b/docs/capabilities.md index 504cad0e..0ac6acd9 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -95,7 +95,7 @@ No Cobuild involved. Scope says what kind of access, and where a write lands. | Connections | `list_connections`, `get_connection_info`, `test_connection` | — | Read-only | | Instance settings | `list_container_exec_configs`, `list_spark_configs`, `get_licensing_status` | — | Read-only | | Data collections & sharing | `list_data_collections`, `list_data_collection_objects`, `list_shared_objects` | — | Read-only | -| Govern | `govern` (catalog and read operations) | `govern` (write and delete operations) | Separate Govern node — stdio only | +| Govern | `govern` (catalog and read operations) | `govern` (write and delete operations) | Separate Govern node — in HTTP mode, calls run as the caller's Dataiku login | The **in-project** writes above configure or supply a project; they do not build its analytic logic. None of them build recipe logic, a model, or an agent. diff --git a/skills/dataiku-headless/references/govern.md b/skills/dataiku-headless/references/govern.md index abb9e7a2..4fe2c0d4 100644 --- a/skills/dataiku-headless/references/govern.md +++ b/skills/dataiku-headless/references/govern.md @@ -37,9 +37,10 @@ node fields of the active instance profile, entered through `configure_instance` Environment variables win. `get_current_instance` shows `govern_url` and whether a Govern key is present; it never shows the key. -The tool works only in local stdio mode. A Govern node accepts API keys, not the -delegated tokens that the HTTP mode uses, so in HTTP mode the tool returns an -error; report it and do not look for another way in. +In HTTP mode, an administrator sets `DKU_GOVERN_URL` and a Govern admin key in +`DKU_GOVERN_API_KEY` on the MCP server. Each call then runs as the caller's +Dataiku login, with that user's Govern permissions. If the login has no Govern +account, the call fails; report it and do not look for another way in. Start a Govern task with `get_instance_info`: it confirms the node type is `GOVERN` and reports the server version. Then `get_auth_info` tells which identity From 77c5d2a51daccefbb5c16ff7900f3f10fa7c9022 Mon Sep 17 00:00:00 2001 From: Pierre Portal Date: Thu, 1 Oct 2026 19:16:46 +0200 Subject: [PATCH 5/9] revert(govern): keep the govern tool stdio-only for the proof of concept --- dataiku_mcp/auth.py | 32 +++++--------------- docs/capabilities.md | 2 +- skills/dataiku-headless/references/govern.md | 7 ++--- 3 files changed, 12 insertions(+), 29 deletions(-) diff --git a/dataiku_mcp/auth.py b/dataiku_mcp/auth.py index 99caa35d..f553562c 100644 --- a/dataiku_mcp/auth.py +++ b/dataiku_mcp/auth.py @@ -48,29 +48,15 @@ def get_govern_client() -> dataikuapi.GovernClient: `DKU_GOVERN_URL` and `DKU_GOVERN_API_KEY` win when set. Otherwise the active instance profile must carry a Govern URL and API key, entered through `configure_instance`. - - Govern accepts only API keys, not delegated tokens. In HTTP mode the - environment key must be a Govern admin key: each call runs as the caller's - Dataiku login through the proxy-user header, so Govern applies that - user's permissions. """ - connection = stdio.get_govern_connection_from_env() - extra_headers = None if request.is_http_request(): - if connection is None: - raise ValueError( - "No Govern node is configured for HTTP mode. An administrator must " - "set DKU_GOVERN_URL and DKU_GOVERN_API_KEY (a Govern admin key) in " - "the MCP server environment." - ) - login = get_dss_client().get_auth_info().get("associatedDSSUser") - if not login: - raise PermissionError( - "The Dataiku token is not tied to a Dataiku user, so the Govern " - "call cannot run as a user." - ) - extra_headers = {"X-DKU-ProxyUser": login} - elif connection is None: + raise ValueError( + "The govern tool is available only in local stdio mode. A Govern " + "node accepts only API keys, not the delegated tokens that HTTP mode " + "uses." + ) + connection = stdio.get_govern_connection_from_env() + if connection is None: current_instance = request.get_pinned_instance() connection = stdio.govern_connection_for_instance(current_instance) if not connection.url: @@ -90,9 +76,7 @@ def get_govern_client() -> dataikuapi.GovernClient: f"The Govern node at {connection.url} has no API key. Set {source}." ) - client = dataikuapi.GovernClient( - connection.url, connection.api_key, extra_headers=extra_headers - ) + client = dataikuapi.GovernClient(connection.url, connection.api_key) client._session.verify = not connection.no_check_certificate return client diff --git a/docs/capabilities.md b/docs/capabilities.md index 0ac6acd9..504cad0e 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -95,7 +95,7 @@ No Cobuild involved. Scope says what kind of access, and where a write lands. | Connections | `list_connections`, `get_connection_info`, `test_connection` | — | Read-only | | Instance settings | `list_container_exec_configs`, `list_spark_configs`, `get_licensing_status` | — | Read-only | | Data collections & sharing | `list_data_collections`, `list_data_collection_objects`, `list_shared_objects` | — | Read-only | -| Govern | `govern` (catalog and read operations) | `govern` (write and delete operations) | Separate Govern node — in HTTP mode, calls run as the caller's Dataiku login | +| Govern | `govern` (catalog and read operations) | `govern` (write and delete operations) | Separate Govern node — stdio only | The **in-project** writes above configure or supply a project; they do not build its analytic logic. None of them build recipe logic, a model, or an agent. diff --git a/skills/dataiku-headless/references/govern.md b/skills/dataiku-headless/references/govern.md index 4fe2c0d4..abb9e7a2 100644 --- a/skills/dataiku-headless/references/govern.md +++ b/skills/dataiku-headless/references/govern.md @@ -37,10 +37,9 @@ node fields of the active instance profile, entered through `configure_instance` Environment variables win. `get_current_instance` shows `govern_url` and whether a Govern key is present; it never shows the key. -In HTTP mode, an administrator sets `DKU_GOVERN_URL` and a Govern admin key in -`DKU_GOVERN_API_KEY` on the MCP server. Each call then runs as the caller's -Dataiku login, with that user's Govern permissions. If the login has no Govern -account, the call fails; report it and do not look for another way in. +The tool works only in local stdio mode. A Govern node accepts API keys, not the +delegated tokens that the HTTP mode uses, so in HTTP mode the tool returns an +error; report it and do not look for another way in. Start a Govern task with `get_instance_info`: it confirms the node type is `GOVERN` and reports the server version. Then `get_auth_info` tells which identity From 11b30667db2cb688212973110cb743436c4b7ca0 Mon Sep 17 00:00:00 2001 From: Pierre Portal Date: Thu, 1 Oct 2026 19:38:09 +0200 Subject: [PATCH 6/9] fix(govern): return compact search hits, plain not-found errors, and keep IDs on replace --- dataiku_mcp/tools/govern.py | 39 +++++++++++++++++-- .../references/govern/artifacts.md | 5 ++- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/dataiku_mcp/tools/govern.py b/dataiku_mcp/tools/govern.py index e2e89caf..19c217a5 100644 --- a/dataiku_mcp/tools/govern.py +++ b/dataiku_mcp/tools/govern.py @@ -41,6 +41,7 @@ GovernArtifactSearchSortName, GovernArtifactSearchSortWorkflow, ) +from dataikuapi.utils import DataikuException from fastmcp import Context from ..server import mcp @@ -160,10 +161,20 @@ def _users_container(value: Any) -> dict[str, Any]: def _replace_raw(definition, new_raw: dict[str, Any]): - """Replace a mutable SDK definition in place so ``save()`` sends ``new_raw``.""" + """Replace a mutable SDK definition in place so ``save()`` sends ``new_raw``. + + Identity keys the caller omits are kept: the operation already names the + object, and Govern refuses a body whose ID does not match the URL. + """ raw = definition.get_raw() + identity = { + key: raw[key] + for key in ("id", "blueprintId") + if key in raw and key not in new_raw + } raw.clear() raw.update(new_raw) + raw.update(identity) return definition @@ -203,7 +214,7 @@ def _pages(client): "search_artifacts", domain="artifacts", kind="read", - description="Search artifacts with optional blueprint, version, artifact, field and archived filters.", + description="Search artifacts with optional blueprint, version, artifact, field and archived filters. Each hit holds id, name, blueprintVersionId and status; get_artifact returns fields and workflow.", sdk="GovernClient.new_artifact_search_request", params=( _p("blueprint_ids", "list", "Blueprint IDs to keep."), @@ -293,7 +304,15 @@ def _search_artifacts(client, p): if len(hits) >= max_results: has_more = True break - hits.append(hit.get_raw()) + # A raw hit embeds the whole blueprint version (about 20 KB). + artifact = hit.get_raw().get("artifact", {}) + hits.append( + { + key: artifact[key] + for key in ("id", "name", "blueprintVersionId", "status") + if key in artifact + } + ) if has_more or len(batch) < page_size: break return {"count": len(hits), "has_more": has_more, "hits": hits} @@ -1908,6 +1927,18 @@ async def govern( def _run(): client = get_govern_client() _require_govern_node(client) - return spec.run(client, params) + try: + return spec.run(client, params) + except DataikuException as err: + # A proxy such as Dataiku Cloud's answers a 404 with an HTML page. + message = str(err).lower() + if " Date: Fri, 2 Oct 2026 10:41:42 +0200 Subject: [PATCH 7/9] fix(govern): add IDs only to metadata updates and name the HTML error page --- dataiku_mcp/tools/govern.py | 56 ++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/dataiku_mcp/tools/govern.py b/dataiku_mcp/tools/govern.py index 19c217a5..b26612db 100644 --- a/dataiku_mcp/tools/govern.py +++ b/dataiku_mcp/tools/govern.py @@ -24,6 +24,7 @@ import hashlib import os +import re import tempfile from collections.abc import Callable from dataclasses import dataclass @@ -161,23 +162,23 @@ def _users_container(value: Any) -> dict[str, Any]: def _replace_raw(definition, new_raw: dict[str, Any]): - """Replace a mutable SDK definition in place so ``save()`` sends ``new_raw``. - - Identity keys the caller omits are kept: the operation already names the - object, and Govern refuses a body whose ID does not match the URL. - """ + """Replace a mutable SDK definition in place so ``save()`` sends ``new_raw``.""" raw = definition.get_raw() - identity = { - key: raw[key] - for key in ("id", "blueprintId") - if key in raw and key not in new_raw - } raw.clear() raw.update(new_raw) - raw.update(identity) return definition +def _with_id(body: dict[str, Any], object_id: str) -> dict[str, Any]: + """Add the ID the operation already names to a small metadata body. + + Govern refuses a blueprint, role or custom page body without its ID. Only + these metadata bodies get it: for larger definitions the missing ID makes + Govern refuse a wrong payload instead of saving it. + """ + return {"id": object_id, **body} + + def _version_state(version) -> dict[str, Any]: return { "definition": version.get_definition().get_raw(), @@ -820,7 +821,9 @@ def _create_blueprint(client, p): ) def _update_blueprint(client, p): blueprint = _admin_blueprint(client, p) - _replace_raw(blueprint.get_definition(), p["blueprint"]).save() + _replace_raw( + blueprint.get_definition(), _with_id(p["blueprint"], p["blueprint_id"]) + ).save() return blueprint.get_definition().get_raw() @@ -875,7 +878,7 @@ def _create_blueprint_version(client, p): _p( "definition", "object", - "Complete version definition from get_blueprint_version.", + "The definition object of get_blueprint_version (not the whole result), edited.", True, ), _p( @@ -886,6 +889,11 @@ def _create_blueprint_version(client, p): ), ) def _update_blueprint_version(client, p): + if "definition" in p["definition"] and "trace" in p["definition"]: + raise ValueError( + "Pass the 'definition' object of get_blueprint_version, not the whole " + "result: saving the result would erase the version's fields and workflow." + ) version = _admin_version(client, p) definition = _replace_raw(version.get_definition(), p["definition"]) definition.save(danger_zone_accepted=p.get("danger_zone_accepted")) @@ -1061,7 +1069,7 @@ def _create_role(client, p): ) def _update_role(client, p): role = _roles(client).get_role(p["role_id"]) - _replace_raw(role.get_definition(), p["role"]).save() + _replace_raw(role.get_definition(), _with_id(p["role"], p["role_id"])).save() return role.get_definition().get_raw() @@ -1332,7 +1340,9 @@ def _create_custom_page(client, p): ) def _update_custom_page(client, p): page = _pages(client).get_custom_page(p["custom_page_id"]) - _replace_raw(page.get_definition(), p["custom_page"]).save() + _replace_raw( + page.get_definition(), _with_id(p["custom_page"], p["custom_page_id"]) + ).save() return page.get_definition().get_raw() @@ -1930,14 +1940,16 @@ def _run(): try: return spec.run(client, params) except DataikuException as err: - # A proxy such as Dataiku Cloud's answers a 404 with an HTML page. - message = str(err).lower() - if "(.*?)", message, re.I | re.S) + title = " ".join(title.group(1).split()) if title else "no title" raise ValueError( - f"Govern answered '{operation}' with an HTML error page, not an " - "API error. The object most likely does not exist or this API " - "key cannot see it; check its identifiers with a list or search " - "operation." + f"Govern answered '{operation}' with an HTML error page " + f"('{title}'), not an API error. On Dataiku Cloud, 'Dataiku " + "instance not found' means the object does not exist or this " + "API key cannot see it; another title is a proxy or server error." ) from None raise From 0aea975b2773cbf17e55bfcfe2a174661c474b0b Mon Sep 17 00:00:00 2001 From: Pierre Portal Date: Fri, 2 Oct 2026 10:48:09 +0200 Subject: [PATCH 8/9] fix(govern): read the Govern node only from the environment and fix review findings --- .mcp.json | 5 +- dataiku_mcp/auth.py | 41 +++----- dataiku_mcp/config/models.py | 20 ---- dataiku_mcp/config/request.py | 1 - dataiku_mcp/config/stdio.py | 59 +---------- dataiku_mcp/setup_server.py | 87 ++++------------ dataiku_mcp/tools/govern.py | 103 ++++++++++++++----- dataiku_mcp/tools/instances.py | 10 +- skills/dataiku-headless-setup/SKILL.md | 4 +- skills/dataiku-headless/references/govern.md | 9 +- 10 files changed, 127 insertions(+), 212 deletions(-) diff --git a/.mcp.json b/.mcp.json index 3c26e57b..6d8516ed 100644 --- a/.mcp.json +++ b/.mcp.json @@ -16,7 +16,10 @@ "DKU_INSTANCE_NAME", "DKU_DSS_URL", "DKU_API_KEY", - "DKU_NO_CHECK_CERTIFICATE" + "DKU_NO_CHECK_CERTIFICATE", + "DKU_GOVERN_URL", + "DKU_GOVERN_API_KEY", + "DKU_GOVERN_NO_CHECK_CERTIFICATE" ] } } diff --git a/dataiku_mcp/auth.py b/dataiku_mcp/auth.py index f553562c..832d2e91 100644 --- a/dataiku_mcp/auth.py +++ b/dataiku_mcp/auth.py @@ -15,13 +15,14 @@ """Authentication and Dataiku client creation.""" import logging +import os from urllib.parse import urlparse import dataikuapi import requests from dataikuapi.utils import DataikuException -from .config import http, request, stdio +from .config import http, request from .executors import run_blocking logger = logging.getLogger("dataiku-mcp") @@ -43,41 +44,25 @@ def get_dss_client() -> dataikuapi.DSSClient: def get_govern_client() -> dataikuapi.GovernClient: - """Get a Govern API client from the environment or the active instance. - - `DKU_GOVERN_URL` and `DKU_GOVERN_API_KEY` win when set. Otherwise the - active instance profile must carry a Govern URL and API key, entered - through `configure_instance`. - """ + """Get a Govern API client from `DKU_GOVERN_URL` and `DKU_GOVERN_API_KEY`.""" if request.is_http_request(): raise ValueError( "The govern tool is available only in local stdio mode. A Govern " "node accepts only API keys, not the delegated tokens that HTTP mode " "uses." ) - connection = stdio.get_govern_connection_from_env() - if connection is None: - current_instance = request.get_pinned_instance() - connection = stdio.govern_connection_for_instance(current_instance) - if not connection.url: - raise ValueError( - f"Dataiku instance '{current_instance.name}' has no Govern node " - "configured. Set DKU_GOVERN_URL and DKU_GOVERN_API_KEY in the MCP " - "server environment, or run configure_instance and fill the " - "Govern node fields." - ) - if not connection.api_key: - source = ( - "DKU_GOVERN_API_KEY" - if connection.source == "environment" - else f"the Govern API key of instance '{connection.instance_name}'" - ) + url = os.environ.get("DKU_GOVERN_URL", "").strip() + api_key = os.environ.get("DKU_GOVERN_API_KEY", "").strip() + if not url or not api_key: raise ValueError( - f"The Govern node at {connection.url} has no API key. Set {source}." + "No Govern node is configured. Set DKU_GOVERN_URL and " + "DKU_GOVERN_API_KEY in the MCP server environment." ) - - client = dataikuapi.GovernClient(connection.url, connection.api_key) - client._session.verify = not connection.no_check_certificate + no_check_certificate = os.environ.get("DKU_GOVERN_NO_CHECK_CERTIFICATE", "").strip() + client = dataikuapi.GovernClient(url, api_key) + client._session.verify = not ( + bool(no_check_certificate) and no_check_certificate.lower() != "false" + ) return client diff --git a/dataiku_mcp/config/models.py b/dataiku_mcp/config/models.py index 7e382821..8e5d2118 100644 --- a/dataiku_mcp/config/models.py +++ b/dataiku_mcp/config/models.py @@ -36,20 +36,6 @@ class DSSInstance: description: str = "" delegated_audience: str = "" delegated_scope: str = "" - govern_url: str = "" - govern_api_key: str = field(default="", repr=False) - govern_no_check_certificate: bool = False - - -@dataclass(frozen=True) -class GovernConnection: - """Resolved Govern node credentials for the govern tool.""" - - url: str - api_key: str = field(repr=False) - no_check_certificate: bool - source: str - instance_name: str = "" NonEmptyString = Annotated[str, StringConstraints(min_length=1)] @@ -71,9 +57,6 @@ class _DSSInstanceConfig(_StrictConfigModel): class StdioDSSInstanceConfig(_DSSInstanceConfig): api_key: NonEmptyString = Field(repr=False) - govern_url: str = "" - govern_api_key: str = Field(default="", repr=False) - govern_no_check_certificate: bool = False def to_instance( self, @@ -88,9 +71,6 @@ def to_instance( no_check_certificate=self.no_check_certificate, source=source, description=self.description, - govern_url=self.govern_url, - govern_api_key=self.govern_api_key, - govern_no_check_certificate=self.govern_no_check_certificate, ) diff --git a/dataiku_mcp/config/request.py b/dataiku_mcp/config/request.py index ffc397f8..ec69a6a3 100644 --- a/dataiku_mcp/config/request.py +++ b/dataiku_mcp/config/request.py @@ -140,5 +140,4 @@ def set_current_instance(name: str) -> dict: "name": selected.name, "url": selected.url, "description": selected.description, - "govern_url": selected.govern_url, } diff --git a/dataiku_mcp/config/stdio.py b/dataiku_mcp/config/stdio.py index 9737c9c2..c0682842 100644 --- a/dataiku_mcp/config/stdio.py +++ b/dataiku_mcp/config/stdio.py @@ -22,12 +22,7 @@ from pydantic import ValidationError from .files import read_json_object, write_json_atomic -from .models import ( - DSSInstance, - GovernConnection, - StdioConfig, - StdioDSSInstanceConfig, -) +from .models import DSSInstance, StdioConfig, StdioDSSInstanceConfig DEFAULT_SETTINGS_PATH = Path.home() / ".dataiku" / "stdio-config.json" @@ -112,26 +107,17 @@ def get_settings_path() -> Path: return _settings_path if _settings_path is not None else set_settings_path(None) -def _parse_no_check_certificate(value: str) -> bool: - value = value.strip() - return bool(value) and value.lower() != "false" - - def _load_instance_from_env_vars() -> DSSInstance | None: if not os.environ.get("DKU_DSS_URL"): return None + no_check_certificate = os.environ.get("DKU_NO_CHECK_CERTIFICATE", "").strip() try: instance = StdioDSSInstanceConfig( url=os.environ["DKU_DSS_URL"], api_key=os.environ.get("DKU_API_KEY", ""), - no_check_certificate=_parse_no_check_certificate( - os.environ.get("DKU_NO_CHECK_CERTIFICATE", "") - ), - govern_url=os.environ.get("DKU_GOVERN_URL", ""), - govern_api_key=os.environ.get("DKU_GOVERN_API_KEY", ""), - govern_no_check_certificate=_parse_no_check_certificate( - os.environ.get("DKU_GOVERN_NO_CHECK_CERTIFICATE", "") + no_check_certificate=( + bool(no_check_certificate) and no_check_certificate.lower() != "false" ), ) except ValidationError as err: @@ -142,36 +128,6 @@ def _load_instance_from_env_vars() -> DSSInstance | None: ) -def get_govern_connection_from_env() -> GovernConnection | None: - """Return the Govern node defined by `DKU_GOVERN_URL`, or None. - - Environment variables win over the active instance profile so a CI job or - a quick test can target a Govern node without editing the config file. - """ - govern_url = os.environ.get("DKU_GOVERN_URL", "").strip() - if not govern_url: - return None - return GovernConnection( - url=govern_url, - api_key=os.environ.get("DKU_GOVERN_API_KEY", ""), - no_check_certificate=_parse_no_check_certificate( - os.environ.get("DKU_GOVERN_NO_CHECK_CERTIFICATE", "") - ), - source="environment", - ) - - -def govern_connection_for_instance(instance: DSSInstance) -> GovernConnection: - """Return the Govern node stored on an instance profile (URL may be empty).""" - return GovernConnection( - url=instance.govern_url, - api_key=instance.govern_api_key, - no_check_certificate=instance.govern_no_check_certificate, - source=instance.source, - instance_name=instance.name, - ) - - def _load_config() -> StdioConfig: path = get_settings_path() try: @@ -240,9 +196,6 @@ def add_instance_to_config( description: str = "", no_check_certificate: bool = False, set_default: bool = False, - govern_url: str = "", - govern_api_key: str = "", - govern_no_check_certificate: bool = False, ) -> dict: """Add an instance to the resolved local profile file.""" global _config @@ -252,9 +205,6 @@ def add_instance_to_config( api_key=api_key, description=description, no_check_certificate=no_check_certificate, - govern_url=govern_url, - govern_api_key=govern_api_key, - govern_no_check_certificate=govern_no_check_certificate, ) config = _load_config() config.dss_instances[name] = instance @@ -266,7 +216,6 @@ def add_instance_to_config( "name": name, "url": url, "description": description, - "govern_url": govern_url, "path": str(get_settings_path()), "default_instance": config.default_instance, } diff --git a/dataiku_mcp/setup_server.py b/dataiku_mcp/setup_server.py index cc569f0f..59f5f815 100644 --- a/dataiku_mcp/setup_server.py +++ b/dataiku_mcp/setup_server.py @@ -47,7 +47,7 @@ class SetupSession: browser_opened: bool completed: threading.Event result: dict | None = None - validated_connection: tuple | None = None + validated_connection: tuple[str, str, bool] | None = None expired: bool = False def close(self) -> None: @@ -57,46 +57,36 @@ def close(self) -> None: self.server.server_close() -def _validate_url(url: str, label: str) -> str: +def _validate_form(form: dict[str, list[str]]) -> dict: + name = form.get("name", [""])[0].strip() + url = form.get("url", [""])[0].strip() + api_key = form.get("api_key", [""])[0].strip() + description = form.get("description", [""])[0].strip() + + if not name: + raise ValueError("Instance name is required.") + if len(name) > 80 or any(ord(character) < 32 for character in name): + raise ValueError("Instance name must be 80 characters or fewer.") if "\\" in url or any( ord(character) < 32 or character.isspace() for character in url ): - raise ValueError(f"{label} contains invalid whitespace or backslashes.") + raise ValueError("Instance URL contains invalid whitespace or backslashes.") try: parsed_url = urlsplit(url) port = parsed_url.port if parsed_url.netloc.endswith(":") or port == 0: raise ValueError except ValueError: - raise ValueError(f"{label} is malformed or contains an invalid port.") from None + raise ValueError( + "Instance URL is malformed or contains an invalid port." + ) from None if parsed_url.scheme not in {"http", "https"} or not parsed_url.hostname: - raise ValueError(f"{label} must be a complete http:// or https:// URL.") + raise ValueError("Instance URL must be a complete http:// or https:// URL.") if parsed_url.username or parsed_url.password: - raise ValueError(f"{label} cannot contain credentials.") - return f"{parsed_url.scheme}://{parsed_url.netloc}" - - -def _validate_form(form: dict[str, list[str]]) -> dict: - name = form.get("name", [""])[0].strip() - url = form.get("url", [""])[0].strip() - api_key = form.get("api_key", [""])[0].strip() - description = form.get("description", [""])[0].strip() - govern_url = form.get("govern_url", [""])[0].strip() - govern_api_key = form.get("govern_api_key", [""])[0].strip() - - if not name: - raise ValueError("Instance name is required.") - if len(name) > 80 or any(ord(character) < 32 for character in name): - raise ValueError("Instance name must be 80 characters or fewer.") - url = _validate_url(url, "Instance URL") + raise ValueError("Instance URL cannot contain credentials.") + url = f"{parsed_url.scheme}://{parsed_url.netloc}" if not api_key: raise ValueError("API key is required.") - if govern_url: - govern_url = _validate_url(govern_url, "Govern URL") - if not govern_api_key: - raise ValueError("Govern API key is required when a Govern URL is set.") - elif govern_api_key: - raise ValueError("Govern URL is required when a Govern API key is set.") return { "name": name, @@ -105,9 +95,6 @@ def _validate_form(form: dict[str, list[str]]) -> dict: "description": description, "no_check_certificate": "no_check_certificate" in form, "set_default": "set_default" in form, - "govern_url": govern_url, - "govern_api_key": govern_api_key, - "govern_no_check_certificate": "govern_no_check_certificate" in form, } @@ -132,12 +119,6 @@ def _page( api_key = escape(values.get("api_key", "")) set_default = " checked" if is_initial_page or values.get("set_default") else "" no_check_certificate = " checked" if values.get("no_check_certificate") else "" - govern_url = escape(values.get("govern_url", "")) - govern_api_key = escape(values.get("govern_api_key", "")) - govern_no_check_certificate = ( - " checked" if values.get("govern_no_check_certificate") else "" - ) - govern_open = " open" if govern_url or govern_api_key else "" save_disabled = "" if connection_validated else " disabled" return f""" @@ -175,7 +156,6 @@ def _page( details {{ margin-top:16px; }} summary {{ color:var(--muted); cursor:pointer; font-size:13px; font-weight:650; }} details .check {{ margin-top:12px; }} - details .grid {{ margin-top:12px; }} .actions {{ display:grid; grid-template-columns:1fr 1fr; gap:12px; }} button {{ width:100%; border:0; border-radius:8px; padding:12px 16px; color:white; background:var(--teal-dark); font:inherit; font-weight:700; line-height:1.2; cursor:pointer; transition:background-color 150ms ease, box-shadow 150ms ease; }} button:hover {{ background:#00696c; }} @@ -208,13 +188,6 @@ def _page(
Advanced options
- Govern node (optional) -
-
Leave empty when this instance has no Govern node. The govern tool uses it.
-
Create one in Govern under Administration → API keys.
-
- - {status_markup}
@@ -264,30 +237,10 @@ def _test_connection(values: dict) -> None: raise ValueError( "Could not connect. Check the URL, API key, and certificate settings." ) from exc - if not values["govern_url"]: - return - try: - govern_client = dataikuapi.GovernClient( - values["govern_url"], values["govern_api_key"] - ) - govern_client._session.verify = not values["govern_no_check_certificate"] - govern_client.get_auth_info() - except Exception as exc: - raise ValueError( - "Could not connect to the Govern node. Check the Govern URL, API key, " - "and certificate settings." - ) from exc -def _connection_settings(values: dict) -> tuple: - return ( - values["url"], - values["api_key"], - values["no_check_certificate"], - values["govern_url"], - values["govern_api_key"], - values["govern_no_check_certificate"], - ) +def _connection_settings(values: dict) -> tuple[str, str, bool]: + return values["url"], values["api_key"], values["no_check_certificate"] def _make_handler(token: str, expected_host: str, session_state: SetupSession | None): diff --git a/dataiku_mcp/tools/govern.py b/dataiku_mcp/tools/govern.py index b26612db..b36a0ffb 100644 --- a/dataiku_mcp/tools/govern.py +++ b/dataiku_mcp/tools/govern.py @@ -72,6 +72,7 @@ class GovernParam: kind: str # string, boolean, integer, object, list, any description: str required: bool = False + path: bool = False # the SDK puts the value into the URL path @dataclass(frozen=True) @@ -105,38 +106,50 @@ def decorator(func): return decorator -def _p(name: str, kind: str, description: str, required: bool = False) -> GovernParam: - return GovernParam(name, kind, description, required) +def _p( + name: str, kind: str, description: str, required: bool = False, path: bool = False +) -> GovernParam: + return GovernParam(name, kind, description, required, path) -ARTIFACT_ID = _p("artifact_id", "string", "Artifact ID, for example ar.42.", True) -STEP_ID = _p("step_id", "string", "Workflow step ID that carries the signoff.", True) +ARTIFACT_ID = _p( + "artifact_id", "string", "Artifact ID, for example ar.42.", True, path=True +) +STEP_ID = _p( + "step_id", "string", "Workflow step ID that carries the signoff.", True, path=True +) BLUEPRINT_ID = _p( "blueprint_id", "string", "Blueprint ID, for example bp.system.govern_project.", True, + path=True, ) VERSION_ID = _p( - "version_id", "string", "Blueprint version ID, for example bv.system.default.", True + "version_id", + "string", + "Blueprint version ID, for example bv.system.default.", + True, + path=True, ) -ROLE_ID = _p("role_id", "string", "Role ID, for example ro.reviewer.", True) +ROLE_ID = _p("role_id", "string", "Role ID, for example ro.reviewer.", True, path=True) CUSTOM_PAGE_ID = _p( - "custom_page_id", "string", "Custom page ID, for example cp.1.", True + "custom_page_id", "string", "Custom page ID, for example cp.1.", True, path=True ) TIME_SERIES_ID = _p( - "time_series_id", "string", "Time series ID, for example ts.7.", True + "time_series_id", "string", "Time series ID, for example ts.7.", True, path=True ) UPLOADED_FILE_ID = _p( - "uploaded_file_id", "string", "Uploaded file ID, for example uf.3.", True + "uploaded_file_id", "string", "Uploaded file ID, for example uf.3.", True, path=True ) -LOGIN = _p("login", "string", "User login.", True) -GROUP_NAME = _p("name", "string", "Group name.", True) +LOGIN = _p("login", "string", "User login.", True, path=True) +GROUP_NAME = _p("name", "string", "Group name.", True, path=True) NEW_IDENTIFIER = _p( "new_identifier", "string", "Bare identifier for the new object; the server adds the type prefix.", True, + path=True, ) USERS_CONTAINER = _p( "users_container", @@ -211,6 +224,28 @@ def _pages(client): # --------------------------------------------------------------------------- +_SEARCH_ID_FILTERS = ("blueprint_ids", "blueprint_version_ids", "artifact_ids") +_FIELD_FILTER_KEYS = { + "condition_type", + "condition", + "field_id", + "negate", + "case_sensitive", +} +_SORT_KEYS = {"type", "direction", "fields"} +_SORT_FIELD_KEYS = {"blueprint_id", "field_id"} + + +def _require_known_keys(value: Any, allowed: set[str], label: str) -> None: + if not isinstance(value, dict): + raise ValueError(f"Each {label} must be an object") + unknown = sorted(set(value) - allowed) + if unknown: + raise ValueError( + f"Unknown keys in {label}: {unknown}. Allowed: {sorted(allowed)}" + ) + + @_register( "search_artifacts", domain="artifacts", @@ -241,6 +276,9 @@ def _pages(client): ), ) def _search_artifacts(client, p): + # An empty ID list matches nothing; dropping the filter would match everything. + if any(p.get(key) == [] for key in _SEARCH_ID_FILTERS): + return {"count": 0, "has_more": False, "hits": []} filters = [] if p.get("blueprint_ids"): filters.append(GovernArtifactFilterBlueprints(list(p["blueprint_ids"]))) @@ -253,6 +291,7 @@ def _search_artifacts(client, p): for field_filter in p.get("field_filters") or []: if not isinstance(field_filter, dict) or not field_filter.get("condition_type"): raise ValueError("Each field_filters entry needs a condition_type") + _require_known_keys(field_filter, _FIELD_FILTER_KEYS, "field_filters entry") filters.append( GovernArtifactFilterFieldValue( field_filter["condition_type"], @@ -268,6 +307,7 @@ def _search_artifacts(client, p): sort = None sort_spec = p.get("sort") if sort_spec: + _require_known_keys(sort_spec, _SORT_KEYS, "sort") sort_type = sort_spec.get("type", "name") direction = sort_spec.get("direction", "ASC") if sort_type == "name": @@ -275,10 +315,13 @@ def _search_artifacts(client, p): elif sort_type == "workflow": sort = GovernArtifactSearchSortWorkflow(direction) elif sort_type == "field": + for field in sort_spec.get("fields") or []: + _require_known_keys(field, _SORT_FIELD_KEYS, "sort.fields entry") + # The SDK puts these into the request body as given, so build them. fields = [ GovernArtifactSearchSortFieldDefinition( field["blueprint_id"], field["field_id"] - ) + ).build() for field in sort_spec.get("fields") or [] ] if not fields: @@ -287,8 +330,8 @@ def _search_artifacts(client, p): else: raise ValueError("sort.type must be name, workflow or field") - page_size = int(p.get("page_size") or 20) - max_results = int(p.get("max_results") or 100) + page_size = int(p["page_size"]) if p.get("page_size") is not None else 20 + max_results = int(p["max_results"]) if p.get("max_results") is not None else 100 if page_size <= 0 or max_results <= 0: raise ValueError("page_size and max_results must be positive") @@ -536,7 +579,7 @@ def _list_signoff_feedbacks(client, p): return [item.get_raw() for item in _signoff(client, p).list_feedbacks()] -FEEDBACK_ID = _p("feedback_id", "string", "Feedback response ID.", True) +FEEDBACK_ID = _p("feedback_id", "string", "Feedback response ID.", True, path=True) FEEDBACK_STATUS = _p( "status", "string", "Feedback status: APPROVED, MINOR_ISSUE or MAJOR_ISSUE.", True ) @@ -1438,7 +1481,7 @@ def _get_time_series_values(client, p): def _push_time_series_values(client, p): datapoints = list(p["datapoints"]) client.get_time_series(p["time_series_id"]).push_values( - datapoints, upsert=bool(p.get("upsert", True)) + datapoints, upsert=p.get("upsert") is not False ) return {"time_series_id": p["time_series_id"], "pushed": len(datapoints)} @@ -1510,7 +1553,7 @@ def _get_uploaded_file(client, p): @_register( "download_uploaded_file", domain="files", - kind="read", + kind="write", description="Download an uploaded file to a local path; never overwrites unless overwrite is true.", sdk="GovernUploadedFile.download", params=( @@ -1542,7 +1585,8 @@ def _download_uploaded_file(client, p): ) as handle: temporary_path = handle.name while True: - chunk = stream.read(_CHUNK_SIZE) + # The raw stream keeps any gzip encoding unless asked to decode. + chunk = stream.read(_CHUNK_SIZE, decode_content=True) if not chunk: break handle.write(chunk) @@ -1632,7 +1676,12 @@ def _get_user(client, p): "LOCAL (default), LDAP, or another identity source.", ), _p("groups", "list", "Group names."), - _p("profile", "string", "User profile; leave empty for the server default."), + _p( + "profile", + "string", + "User profile. When omitted, the SDK sends DATA_SCIENTIST; the tool does " + "not check it against the license.", + ), _p("email", "string", "Email address."), ), ) @@ -1827,7 +1876,14 @@ def _validate_params( if not _KIND_CHECKS[param.kind](value): raise ValueError(f"Param '{name}' must be a {param.kind}") if param.kind == "string" and param.required: - require_non_empty_string(value, name) + value = require_non_empty_string(value, name) + if param.path: + if value in (".", "..") or any(char in value for char in "/\\?#%"): + raise ValueError( + f"Param '{name}' must be a plain identifier, without '/', '\\', " + "'?', '#' or '%'" + ) + params[name] = value def _operation_summary(operation_id: str, operation: GovernOperation) -> dict[str, Any]: @@ -1870,8 +1926,8 @@ def _catalog(domain: str) -> str: def _require_govern_node(client) -> None: """Check once per host that the URL targets a GOVERN node. - `/instance-info` is permission-gated, so an unreadable answer is not an - error; the node type is then trusted as configured. + Only a confirmed GOVERN answer is cached. When `/instance-info` fails, the + check runs again on the next call and the operation reports its own error. """ host = getattr(client, "host", "") if host in _verified_hosts: @@ -1879,12 +1935,11 @@ def _require_govern_node(client) -> None: try: node_type = client.get_instance_info().node_type except Exception: - _verified_hosts.add(host) return if node_type != "GOVERN": raise ValueError( f"The configured Govern URL {host} points to a {node_type} node, not a " - "Govern node. Check DKU_GOVERN_URL or the Govern URL of the active instance." + "Govern node. Check DKU_GOVERN_URL." ) _verified_hosts.add(host) diff --git a/dataiku_mcp/tools/instances.py b/dataiku_mcp/tools/instances.py index ea5af274..0ed0a666 100644 --- a/dataiku_mcp/tools/instances.py +++ b/dataiku_mcp/tools/instances.py @@ -59,13 +59,10 @@ async def list_instances(ctx: Context) -> str: "name": name, "url": inst.url, "description": inst.description, - "govern_url": inst.govern_url, "active": name == current_instance_name, } ) - return compact_json( - columnar(result, ["name", "url", "description", "govern_url", "active"]) - ) + return compact_json(columnar(result, ["name", "url", "description", "active"])) @mcp.tool( @@ -120,11 +117,6 @@ async def get_current_instance(ctx: Context) -> str: # Strip api_key from return value current_instance = asdict(request.get_pinned_instance()) current_instance.pop("api_key", None) - govern_api_key = current_instance.pop("govern_api_key", "") - if current_instance.get("govern_url"): - current_instance["govern_api_key_configured"] = bool(govern_api_key) - else: - current_instance.pop("govern_no_check_certificate", None) current_instance["connection_status"] = "failed" try: client = get_dss_client() diff --git a/skills/dataiku-headless-setup/SKILL.md b/skills/dataiku-headless-setup/SKILL.md index 9f577bcc..8ab3041c 100644 --- a/skills/dataiku-headless-setup/SKILL.md +++ b/skills/dataiku-headless-setup/SKILL.md @@ -22,7 +22,7 @@ Bring a new or broken plugin installation to a verified Dataiku connection. A re 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. 7. Retry `list_instances`. If the tools are still unavailable, reload the plugin or restart the agent once, then retry before diagnosing a Dataiku connection problem. -8. When `list_instances` succeeds, if no instance is configured, run `configure_instance` and have the user complete the local setup page. The page has optional Govern node fields (URL and API key) for users who also work in Dataiku Govern; the `govern` tool needs them, or the `DKU_GOVERN_URL` and `DKU_GOVERN_API_KEY` environment variables. If multiple instances exist without an active one, ask which to use and run `switch_instance`. +8. When `list_instances` succeeds, if no instance is configured, run `configure_instance` and have the user complete the local setup page. If multiple instances exist without an active one, ask which to use and run `switch_instance`. 9. Verify the active profile with `get_current_instance`. If its `connection_status` is `failed`, the discovered profile cannot connect to Dataiku; run `configure_instance` to replace or add a working profile instead of treating it as set up. When it is `connected`, make a lightweight read-only Dataiku call such as `list_projects` to validate the available Dataiku access. Never request or repeat the API key in chat. -Report completion with the active instance name and URL, Dataiku version when available, the Govern URL when configured, and whether the Dataiku read succeeded. Include the uv version only when runtime recovery was needed. If a restart is required, say that setup is incomplete and give the single next action. +Report completion with the active instance name and URL, Dataiku version when available, and whether the Dataiku read succeeded. Include the uv version only when runtime recovery was needed. If a restart is required, say that setup is incomplete and give the single next action. diff --git a/skills/dataiku-headless/references/govern.md b/skills/dataiku-headless/references/govern.md index abb9e7a2..b1590b5e 100644 --- a/skills/dataiku-headless/references/govern.md +++ b/skills/dataiku-headless/references/govern.md @@ -30,12 +30,11 @@ DSS project work still uses the other MCP tools and Cobuild. ## Connect -The Govern node needs its own URL and API key. The tool reads them from +The Govern node needs its own URL and API key. The tool reads them only from `DKU_GOVERN_URL` and `DKU_GOVERN_API_KEY` in the MCP server environment -(`DKU_GOVERN_NO_CHECK_CERTIFICATE` is optional), and otherwise from the Govern -node fields of the active instance profile, entered through `configure_instance`. -Environment variables win. `get_current_instance` shows `govern_url` and whether a -Govern key is present; it never shows the key. +(`DKU_GOVERN_NO_CHECK_CERTIFICATE` is optional). Instance profiles and +`configure_instance` do not hold Govern settings. When the variables are missing, +the tool says so; ask the user to set them and restart the agent. The tool works only in local stdio mode. A Govern node accepts API keys, not the delegated tokens that the HTTP mode uses, so in HTTP mode the tool returns an From 6dc4b90515e06c0a71e1c75bac91c514152b871b Mon Sep 17 00:00:00 2001 From: Pierre Portal Date: Mon, 5 Oct 2026 20:06:47 +0200 Subject: [PATCH 9/9] feat(govern): use the active govern instance instead of environment variables --- .mcp.json | 5 +- README.md | 4 +- dataiku_mcp/auth.py | 28 +++++---- dataiku_mcp/tools/govern.py | 4 +- docs/capabilities.md | 2 +- skills/dataiku-headless/SKILL.md | 4 +- skills/dataiku-headless/references/govern.md | 9 ++- tests/test_auth.py | 61 ++++++++++++++++++++ 8 files changed, 90 insertions(+), 27 deletions(-) diff --git a/.mcp.json b/.mcp.json index 97fc4ca8..d79ad081 100644 --- a/.mcp.json +++ b/.mcp.json @@ -24,10 +24,7 @@ "DKU_BACKEND_HOST", "DKU_BACKEND_PORT", "DKU_API_TICKET", - "DKU_SERVER_CERT", - "DKU_GOVERN_URL", - "DKU_GOVERN_API_KEY", - "DKU_GOVERN_NO_CHECK_CERTIFICATE" + "DKU_SERVER_CERT" ] } } diff --git a/README.md b/README.md index 250950c1..242cbb62 100644 --- a/README.md +++ b/README.md @@ -177,8 +177,8 @@ Each profile requires `instance_type`: `design`, `automation`, `deployer`, `gove `agent-management`. Existing stdio profiles missing this field are automatically assigned `design` and rewritten on disk before loading. This temporary migration is scheduled for deprecation by 0.9.0; existing values are preserved and validated. -The type metadata does not change client selection or tool availability. -Selecting Govern records its type; Govern-specific API tools are not yet implemented. +The type metadata does not change tool availability. Only the `govern` tool reads +it: that tool runs only when the active instance has type `govern`. **Environment override.** To select an explicit target, including inside a Code Studio, set these three variables in your environment or copy diff --git a/dataiku_mcp/auth.py b/dataiku_mcp/auth.py index c678812b..d53424ef 100644 --- a/dataiku_mcp/auth.py +++ b/dataiku_mcp/auth.py @@ -15,7 +15,6 @@ """Authentication and Dataiku client creation.""" import logging -import os from urllib.parse import urlparse import dataikuapi @@ -59,24 +58,31 @@ def get_dss_client() -> dataikuapi.DSSClient: def get_govern_client() -> dataikuapi.GovernClient: - """Get a Govern API client from `DKU_GOVERN_URL` and `DKU_GOVERN_API_KEY`.""" + """Get a Govern API client for the active instance of type `govern`.""" if request.is_http_request(): raise ValueError( "The govern tool is available only in local stdio mode. A Govern " "node accepts only API keys, not the delegated tokens that HTTP mode " "uses." ) - url = os.environ.get("DKU_GOVERN_URL", "").strip() - api_key = os.environ.get("DKU_GOVERN_API_KEY", "").strip() - if not url or not api_key: + current_instance = request.get_pinned_instance() + if current_instance.instance_type != "govern": raise ValueError( - "No Govern node is configured. Set DKU_GOVERN_URL and " - "DKU_GOVERN_API_KEY in the MCP server environment." + f"The active instance '{current_instance.name}' has type " + f"'{current_instance.instance_type}'. The govern tool needs an instance " + "of type 'govern': run switch_instance to one, or configure_instance " + "to add one." ) - no_check_certificate = os.environ.get("DKU_GOVERN_NO_CHECK_CERTIFICATE", "").strip() - client = dataikuapi.GovernClient(url, api_key) - client._session.verify = not ( - bool(no_check_certificate) and no_check_certificate.lower() != "false" + client = dataikuapi.GovernClient( + current_instance.url, + api_key=current_instance.api_key, + internal_ticket=current_instance.api_ticket, + extra_headers={"X-DKU-Client-Application": "dataiku-headless"}, + ) + client._session.verify = ( + False + if current_instance.no_check_certificate + else current_instance.encrypted_rpc_cert_path or True ) return client diff --git a/dataiku_mcp/tools/govern.py b/dataiku_mcp/tools/govern.py index b36a0ffb..25b27fc3 100644 --- a/dataiku_mcp/tools/govern.py +++ b/dataiku_mcp/tools/govern.py @@ -1938,8 +1938,8 @@ def _require_govern_node(client) -> None: return if node_type != "GOVERN": raise ValueError( - f"The configured Govern URL {host} points to a {node_type} node, not a " - "Govern node. Check DKU_GOVERN_URL." + f"The active instance URL {host} points to a {node_type} node, not a " + "Govern node. Check the URL and instance_type of the active instance." ) _verified_hosts.add(host) diff --git a/docs/capabilities.md b/docs/capabilities.md index 839216b9..3a518d9b 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -95,7 +95,7 @@ No Cobuild involved. Scope says what kind of access, and where a write lands. | Connections | `list_connections`, `get_connection_info`, `test_connection` | — | Read-only | | Instance settings | `list_container_exec_configs`, `list_spark_configs`, `get_licensing_status` | — | Read-only | | Data collections & sharing | `list_data_collections`, `list_data_collection_objects`, `list_shared_objects` | — | Read-only | -| Govern | `govern` (catalog and read operations) | `govern` (write and delete operations) | Separate Govern node — stdio only | +| Govern | `govern` (catalog and read operations) | `govern` (write and delete operations) | Instance of type `govern` — stdio only | The **in-project** writes above configure or supply a project; they do not build its analytic logic. None of them build recipe logic, a model, or an agent. diff --git a/skills/dataiku-headless/SKILL.md b/skills/dataiku-headless/SKILL.md index 0f4c762c..bc32d025 100644 --- a/skills/dataiku-headless/SKILL.md +++ b/skills/dataiku-headless/SKILL.md @@ -7,12 +7,12 @@ description: Use for Dataiku tasks including projects, flows, datasets, recipes, Use this for any Dataiku task. Choose the right reference guide first, inspect the current state before acting, route project changes through Cobuild by default, and validate by re-reading the resulting state. -For **Dataiku Govern**, start with [Govern](./references/govern.md). Govern runs on its own node and is reached through the single `govern` tool, which needs a Govern URL and API key of its own. +For **Dataiku Govern**, start with [Govern](./references/govern.md). Govern runs on its own node and is reached through the single `govern` tool, which uses an instance of type `govern`. ## Shared Operating Rules 1. If the user asks to install, set up, connect, or repair Dataiku Headless, or its MCP tools are unavailable just after installation, read `../dataiku-headless-setup/SKILL.md`. Choose either local stdio or customer-managed HTTP setup and never enable both. -2. Ensure an instance is configured before any Dataiku work. In local stdio mode, if `get_current_instance` errors or `list_instances` is empty, run `configure_instance` first. In HTTP mode, use `list_instances` then `switch_instance`; the instance catalog is platform-managed. Keep the reported `dataiku_version` in context for version-sensitive requests. The required `instance_type` is configured metadata, not API verification or a guarantee of client support. Govern work also needs the Govern node configured; its guide says how. +2. Ensure an instance is configured before any Dataiku work. In local stdio mode, if `get_current_instance` errors or `list_instances` is empty, run `configure_instance` first. In HTTP mode, use `list_instances` then `switch_instance`; the instance catalog is platform-managed. Keep the reported `dataiku_version` in context for version-sensitive requests. The required `instance_type` is configured metadata, not API verification or a guarantee of client support. Govern work also needs an instance of type `govern`; its guide says how. 3. Discover project keys and object identifiers through tools; do not invent them. 4. Read before write. Inspect the current object, flow context, jobs, or run history before changing anything. 5. Treat the matching reference guide as the source of truth for object-specific concepts, inspection steps, and required references. diff --git a/skills/dataiku-headless/references/govern.md b/skills/dataiku-headless/references/govern.md index b1590b5e..1f110323 100644 --- a/skills/dataiku-headless/references/govern.md +++ b/skills/dataiku-headless/references/govern.md @@ -30,11 +30,10 @@ DSS project work still uses the other MCP tools and Cobuild. ## Connect -The Govern node needs its own URL and API key. The tool reads them only from -`DKU_GOVERN_URL` and `DKU_GOVERN_API_KEY` in the MCP server environment -(`DKU_GOVERN_NO_CHECK_CERTIFICATE` is optional). Instance profiles and -`configure_instance` do not hold Govern settings. When the variables are missing, -the tool says so; ask the user to set them and restart the agent. +The Govern node is a configured instance of type `govern`, with its own URL and +API key. The tool uses the active instance and refuses an instance of another +type. `list_instances` shows the type of each instance; when none has type +`govern`, run `configure_instance` and choose the Govern type. The tool works only in local stdio mode. A Govern node accepts API keys, not the delegated tokens that the HTTP mode uses, so in HTTP mode the tool returns an diff --git a/tests/test_auth.py b/tests/test_auth.py index 6bbf3342..179393e0 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -163,3 +163,64 @@ def log_message(self, *args): server.shutdown() server.server_close() thread.join(timeout=3) + + +@pytest.mark.parametrize("no_check_certificate", [False, True]) +@pytest.mark.parametrize("credential", ["api_key", "api_ticket"]) +def test_govern_client_uses_active_govern_instance( + monkeypatch, credential, no_check_certificate +): + instance = DSSInstance( + name="govern", + url="https://govern.example.com", + api_key="api-key" if credential == "api_key" else None, + api_ticket="api-ticket" if credential == "api_ticket" else None, + no_check_certificate=no_check_certificate, + source="config", + instance_type="govern", + ) + monkeypatch.setattr(request, "get_pinned_instance", lambda: instance) + monkeypatch.setattr(request, "is_http_request", lambda: False) + captured = {} + client = SimpleNamespace(_session=SimpleNamespace(verify=True)) + + def create_client(url, **kwargs): + captured.update(url=url, **kwargs) + return client + + monkeypatch.setattr(auth.dataikuapi, "GovernClient", create_client) + + assert auth.get_govern_client() is client + assert captured == { + "url": instance.url, + "api_key": instance.api_key, + "internal_ticket": instance.api_ticket, + "extra_headers": {"X-DKU-Client-Application": "dataiku-headless"}, + } + assert client._session.verify is not no_check_certificate + + +@pytest.mark.parametrize( + "instance_type", ["design", "automation", "deployer", "agent-management"] +) +def test_govern_client_rejects_other_instance_types(monkeypatch, instance_type): + instance = DSSInstance( + name="dev", + url="https://dev.example.com", + api_key="api-key", + no_check_certificate=False, + source="config", + instance_type=instance_type, + ) + monkeypatch.setattr(request, "get_pinned_instance", lambda: instance) + monkeypatch.setattr(request, "is_http_request", lambda: False) + + with pytest.raises(ValueError, match="switch_instance"): + auth.get_govern_client() + + +def test_govern_client_is_stdio_only(monkeypatch): + monkeypatch.setattr(request, "is_http_request", lambda: True) + + with pytest.raises(ValueError, match="local stdio mode"): + auth.get_govern_client()