diff --git a/dataiku_mcp/tools/cobuild.py b/dataiku_mcp/tools/cobuild.py index 5859f9a7..00c3b6bc 100644 --- a/dataiku_mcp/tools/cobuild.py +++ b/dataiku_mcp/tools/cobuild.py @@ -23,6 +23,7 @@ from datetime import datetime, timezone from typing import Annotated, Literal +from dataikuapi.utils import DataikuException from fastmcp import Context from pydantic import Field @@ -249,6 +250,65 @@ async def _wait_for_turn( return result +def _any_project_key(client) -> str: + """One project key. Root folder response is small; instance-wide list + carries every project's metadata, megabytes on a large instance.""" + keys = ( + client.get_root_project_folder().list_project_keys() + or client.list_project_keys() + ) + if not keys: + raise ValueError("Instance holds 0 projects. Cobuild check needs 1.") + return keys[0] + + +@mcp.tool( + title="Get Cobuild Status", + annotations={ + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": False, + }, +) +async def get_cobuild_status( + ctx: Context, + project_key: Annotated[ + str, + Field(description="Project to probe. Empty borrows any accessible one."), + ] = "", +) -> str: + """Check Cobuild availability for these credentials on this instance.""" + # Temporary. Opening a conversation is the only way to ask today, and it + # leaves an empty one in the probed project. Replace with + # DSSClient.get_cobuild_status() when it ships. + requested_key = project_key.strip() + await ctx.info("Checking Cobuild status...") + instance = get_current_instance_for_tool() + + def _run(): + client = get_dss_client() + probed_key = requested_key or _any_project_key(client) + try: + client.get_project(probed_key).new_cobuild_conversation() + except DataikuException as exc: + raise ValueError( + f"Cobuild refused project '{probed_key}': {exc}. Causes: Cobuild " + "off on this instance, credentials lack a personal API key, project " + "lacks read access. Retry with a readable project_key." + ) from exc + return probed_key + + probed_key = await run_blocking(_run) + return compact_json( + { + "enabled": True, + "instance_name": instance.name, + "probed_project_key": probed_key, + } + ) + + @mcp.tool( title="Start Cobuild Conversation", annotations={ diff --git a/docs/capabilities.md b/docs/capabilities.md index bde51785..eef26d99 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -22,14 +22,14 @@ instance the local client targets. ## Surface -**128 tools** · 93 read · 21 direct Dataiku write · 6 Cobuild · 4 execute · 3 local +**129 tools** · 93 read · 21 direct Dataiku write · 7 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 | -| Cobuild conversation | 6 | All flow and analytic building | +| Cobuild conversation | 7 | 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` | | Connection test | 1 | `test_connection` | @@ -65,6 +65,7 @@ empty managed folder (see *Handled directly by Headless*). It cannot build anyth | Tool | Use | |---|---| +| `get_cobuild_status` | Cobuild availability for these credentials | | `start_cobuild_conversation` | Open a retained conversation on a project | | `send_cobuild_message` | Ask Cobuild to inspect a project, or to build or change something — edits are opt-in via allow_edit_project | | `answer_cobuild_question` | Answer a question Cobuild asked, resuming its pending work | diff --git a/skills/dataiku-headless/references/cobuild.md b/skills/dataiku-headless/references/cobuild.md index 727fc47e..ba2187e5 100644 --- a/skills/dataiku-headless/references/cobuild.md +++ b/skills/dataiku-headless/references/cobuild.md @@ -17,6 +17,7 @@ Use this guide as the default path for project-level asset creation. This includ - Cobuild can inspect project context, propose changes, and make permitted changes through the same conversation. - Deletion confirmations and questions are separate response steps bound to their exact `turn_id`. A request to edit does not authorize a broader or unexpected deletion. - Conversations and turns are retained only in the MCP server process and are lost when it restarts. +- Cobuild availability = instance+user gate. `get_cobuild_status` checks it before any conversation. `enabled=true` covers those credentials; LLM backend and project permissions stay unproven. - Every Cobuild payload carries `project_url`, the Dataiku UI URL of the conversation's project on the instance that conversation is pinned to. Dataiku exposes no per-conversation URL: Cobuild opens as a panel inside the project, so `project_url` points at the project and the user opens Cobuild from there. ## When To Use This Skill @@ -61,6 +62,7 @@ Do not use this guide when: | Goal | Tool | | --- | --- | +| Check Cobuild availability | `get_cobuild_status` | | Start a new Cobuild conversation for a project | `start_cobuild_conversation` | | Continue a Cobuild conversation | `send_cobuild_message` | | Approve or cancel a Cobuild delete confirmation request | `answer_cobuild_confirmation` | @@ -87,4 +89,5 @@ Do not use this guide when: - Approve a deletion only when its scope clearly matches the user's stated intent. If it is broader, ambiguous, or surprising, clarify with the user before responding. - Before triggering a build-affecting prompt, check `./jobs.md` if there's any chance the same flow objects are already mid-build elsewhere — don't kick off overlapping work. - If Cobuild cannot perform the request and no direct tool covers it, stop and report the gap. Include the Dataiku version when known, but attribute the gap to that version only when the requirement is established; otherwise, do not guess or fall back to raw APIs. +- `get_cobuild_status` probes by opening a conversation. Leaves one permanent "Empty chat" in the probed project, visible to the probing account alone, removable by hand in its Cobuild panel. Pass a `project_key` the user owns. - There is no close or delete conversation tool. diff --git a/tests/test_cobuild.py b/tests/test_cobuild.py index 0e996836..5d624fef 100644 --- a/tests/test_cobuild.py +++ b/tests/test_cobuild.py @@ -22,6 +22,7 @@ import pytest from dataiku_mcp.tools import cobuild +from dataikuapi.utils import DataikuException class Context: @@ -109,9 +110,25 @@ def answer_question(self, answers, *, rejected=False, used_custom_answer=False): class Client: def __init__(self, conversation=None): self.conversation = conversation or Conversation() + self.root_project_keys = ["ROOT_PROJECT"] + self.all_project_keys = ["NESTED_PROJECT"] + self.listed_all_projects = False + self.open_error = None def get_project(self, _project_key): - return SimpleNamespace(new_cobuild_conversation=lambda: self.conversation) + def new_cobuild_conversation(): + if self.open_error: + raise self.open_error + return self.conversation + + return SimpleNamespace(new_cobuild_conversation=new_cobuild_conversation) + + def get_root_project_folder(self): + return SimpleNamespace(list_project_keys=lambda: list(self.root_project_keys)) + + def list_project_keys(self): + self.listed_all_projects = True + return list(self.all_project_keys) @pytest.fixture(autouse=True) @@ -586,3 +603,30 @@ async def scenario(): assert (await start())["project_url"] == PROJECT_URL run(scenario()) + + +def test_cobuild_status_probes_without_the_instance_project_list(environment): + client, _ = environment + + async def scenario(): + assert json.loads(await cobuild.get_cobuild_status(Context())) == { + "enabled": True, + "instance_name": "instance-a", + "probed_project_key": "ROOT_PROJECT", + } + assert client.listed_all_projects is False + + client.root_project_keys = [] + status = json.loads(await cobuild.get_cobuild_status(Context())) + assert status["probed_project_key"] == "NESTED_PROJECT" + assert client.listed_all_projects is True + + client.all_project_keys = [] + with pytest.raises(ValueError, match="Instance holds 0 projects"): + await cobuild.get_cobuild_status(Context()) + + client.open_error = DataikuException("403 forbidden") + with pytest.raises(ValueError, match="Cobuild refused project 'GIVEN'"): + await cobuild.get_cobuild_status(Context(), "GIVEN") + + run(scenario()) diff --git a/tests/test_tool_surface.py b/tests/test_tool_surface.py index fdabff35..b6040353 100644 --- a/tests/test_tool_surface.py +++ b/tests/test_tool_surface.py @@ -45,6 +45,7 @@ { "answer_cobuild_confirmation", "answer_cobuild_question", + "get_cobuild_status", "get_cobuild_turn_status", "list_cobuild_conversations", "send_cobuild_message",