diff --git a/.dataiku/http-config.json.entra-example b/.dataiku/http-config.json.entra-example index 7cd52239..451f482e 100644 --- a/.dataiku/http-config.json.entra-example +++ b/.dataiku/http-config.json.entra-example @@ -15,8 +15,8 @@ }, "dss_instances": { "prod": { - "url": "https://dss.example", - "delegated_scope": "api://replace-with-dss-app-client-id/dss.access" + "url": "https://dataiku.example", + "delegated_scope": "api://replace-with-dataiku-app-client-id/dataiku.access" } }, "user_selections": {} diff --git a/.dataiku/http-config.json.generic_oidc-example b/.dataiku/http-config.json.generic_oidc-example index e67e7989..34fb7a7b 100644 --- a/.dataiku/http-config.json.generic_oidc-example +++ b/.dataiku/http-config.json.generic_oidc-example @@ -23,9 +23,9 @@ }, "dss_instances": { "prod": { - "url": "https://dss.example", - "delegated_audience": "dss-prod", - "delegated_scope": "dss.api" + "url": "https://dataiku.example", + "delegated_audience": "dataiku-prod", + "delegated_scope": "dataiku.api" } }, "user_selections": {} diff --git a/.gitignore b/.gitignore index cbfbc413..6df31df1 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ migration_token_diet.md sample_extracted/ extract/ *_extracted/ + +# Default http plugin bundle location +dataiku-headless-http/ +dataiku-headless-http.zip diff --git a/README.md b/README.md index 45e37e76..11306fde 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,18 @@ Dataiku Headless is an MCP server with tools for working in Dataiku, plus skills that teach AI assistants how to use them. Connect it to a Dataiku instance, and your AI assistant can build data pipelines, models, dashboards, agents, and more. -Install it from the [Claude Code](#claude-code-cli) or [Codex](#codex-cli) plugin marketplace, or install it as an agent plugin from this GitHub repository for Cursor, Snowflake CoCo, AWS Kiro, OpenCode, and more. +Dataiku Headless supports two connection modes: + +| Mode | MCP server | Authentication | Installation | +| --- | --- | --- | --- | +| Local stdio | Runs on the user's workstation | Personal Dataiku API key | Install the local plugin | +| Customer-managed HTTP | Runs as an organization-managed service | Enterprise OAuth and delegated Dataiku identity | Install the customer-specific remote plugin distributed by the administrator | + +Do not enable both Dataiku MCP definitions in the same client. They expose the same tools with different credential ownership and can cause the agent to target the wrong server. + +The rest of this README covers the Dataiku Headless marketplace plugin, which uses stdio transport. For customer-managed HTTP installation, endpoint distribution, OAuth login, and end-user verification, see [Streamable HTTP deployment](docs/http-deployment.md#distribute-the-interactive-oauth-plugin). + +Install the plugin 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. ## Requirements @@ -151,7 +162,7 @@ The reference library covers the main Dataiku object areas and workflows, includ ## Stdio onboarding and authentication -The onboarding flow is the same: +The onboarding flow is: 1. Ask the agent to **Set up Dataiku Headless** (or run `/dataiku-headless:dataiku-headless-setup` in Claude Code). 2. Approve the MCP URL prompt. @@ -273,7 +284,7 @@ uv run --quiet --locked --script ./runtime/run_mcp.py --transport stdio │ ├── run_mcp.py # Server entry point: PEP 723 script pinning the runtime deps inline │ └── run_mcp.py.lock # Committed, full dependency resolution for the entry point ├── .claude-plugin/ -│ ├── plugin.json # Claude Code plugin manifest (skills + unconfigured stdio MCP) +│ ├── plugin.json # Claude Code plugin manifest (skills + stdio MCP) │ └── marketplace.json # Marketplace catalog (single-plugin, source: "./") ├── .codex-plugin/ │ └── plugin.json # Codex plugin manifest diff --git a/docs/http-deployment.md b/docs/http-deployment.md index 81ebbcca..5c0ea3fa 100644 --- a/docs/http-deployment.md +++ b/docs/http-deployment.md @@ -3,12 +3,12 @@ Streamable HTTP runs Dataiku Headless as a centrally managed service, rather than as a local process on each user's computer. An infrastructure administrator deploys the MCP server on a host that users can reach, exposes it through HTTPS, and gives -it network access to one or more DSS instances. +it network access to one or more Dataiku instances. -The administrator also configures OAuth and a catalog of those DSS instances. +The administrator also configures OAuth and a catalog of those Dataiku instances. Authenticated users can select an instance from the catalog, but cannot add or -delete entries. The MCP server does not store DSS API keys: it exchanges each -user's MCP access token for a short-lived token that DSS accepts. +delete entries. The MCP server does not store Dataiku API keys: it exchanges each +user's MCP access token for a short-lived token that Dataiku accepts. Clients can authenticate to the MCP server in two ways: @@ -26,40 +26,63 @@ token exchange. | --- | --- | --- | | Deployment | Local process started by a plugin | Shared remote service | | MCP authentication | Local process boundary | OAuth access token | -| DSS authentication | API key | Exchanged user access token | +| Dataiku authentication | API key | Exchanged user access token | | Active instance | Process-wide | Selected per authenticated user | +| Local file operations | Can read/write workstation paths | Cannot access caller workstation paths | -## How a request reaches DSS +Streamable HTTP runs on the shared server, not the user's workstation. Tools that +need a caller-supplied local path—such as local CSV export, file upload, +project-library writes, or local plugin sources—are unavailable. Follow the +object-specific skill guidance for supported HTTP alternatives. + +## How a request reaches Dataiku An access token's **audience** identifies the service allowed to accept it. Its **scope** describes the access granted to the caller. The token sent to the MCP -server therefore cannot be sent directly to DSS: it has the wrong audience. +server therefore cannot be sent directly to Dataiku: it has the wrong audience. -For each DSS-backed tool call: +For each Dataiku-backed tool call: 1. The client obtains an MCP-audience access token, interactively or directly, and sends it to the MCP server. 2. The MCP server verifies its signature, issuer, audience, required scope, and expiry. -3. The server identifies the user and resolves the DSS instance they selected. +3. The server identifies the user and resolves the Dataiku instance they selected. 4. It asks the identity provider to exchange the MCP token for a **delegated token** - whose audience and scope match that DSS instance. -5. DSS verifies the delegated token, maps its subject to a DSS user, and applies - that user's normal DSS permissions. + whose audience and scope match that Dataiku instance. +5. Dataiku verifies the delegated token, maps its subject to a Dataiku user, and + applies that user's normal Dataiku permissions. + +## Choose client authentication + +For an interactive Codex or Claude deployment, use **interactive login**. Set +`server.public_url`, configure `auth.interactive_login`, and distribute the +rendered HTTP plugin described below. + +For a custom caller that manages its own OAuth, use a **direct bearer token**. +The caller obtains and sends the MCP access token; remove `interactive_login` and +make `server.public_url` optional. The rendered plugin can still provide the +remote endpoint and skills; the caller remains responsible for its token. ## Deploy the server Before starting, prepare: - a server with DNS and HTTPS, either directly or through a reverse proxy; -- network access from that server to every configured DSS URL; +- network access from that server to every configured Dataiku URL; - the identity-provider configuration for one of the supported exchange modes; and -- administrator access to configure JWT authentication on each DSS instance. +- administrator access to configure JWT authentication on each Dataiku instance. If using a reverse proxy, forward the original scheme and host. Route the whole public origin to the MCP server: OAuth metadata, login, callback, consent, and token endpoints are served outside the configured MCP path. +Complete the matching identity-provider setup before writing the settings file: +[Microsoft Entra ID](#microsoft-entra-id) or +[generic OIDC and RFC 8693](#generic-oidc-and-rfc-8693). Also complete +[Dataiku JWT trust](#configure-dataiku-to-trust-delegated-jwts) for every configured +Dataiku instance. + Choose the matching example, then restrict the settings file because it contains OAuth client secrets: @@ -79,7 +102,7 @@ The settings file has four sections: - `server` controls the listening address, MCP path, and externally visible URL. - `auth` configures incoming-token verification, optional interactive login, and token exchange. -- `dss_instances` lists the DSS endpoints users may select and the audience or +- `dss_instances` lists the Dataiku endpoints users may select and the audience or scope requested for each one. - `user_selections` records each authenticated user's current instance. Start with an empty object; the server manages it. @@ -90,28 +113,32 @@ as users call `switch_instance`. This file-backed selection state supports one server process. The instance catalog is not an authorization list: any authenticated user can -select an entry, and DSS decides what that user may do. `configure_instance` and +select an entry, and Dataiku decides what that user may do. `configure_instance` and `delete_instance` are therefore disabled in HTTP mode. -## Host-local file operations +## Distribute the interactive OAuth plugin -Streamable HTTP does not expose reads from or writes to caller-supplied paths on the -MCP host. In HTTP mode, `export_dataset`, `upload_file_to_managed_folder`, -`write_project_library_file`, and `update_plugin` are unavailable. To create an -Uploaded Files dataset, pass `columns` and `rows` directly to -`create_upload_dataset`; this route accepts at most 10,000 rows. Stdio retains the -local-path workflows for a process running on the user's machine. +The public `dataiku-headless` marketplace plugin starts its own stdio server and +must not be installed for this mode. -### Interactive login or direct bearer token +1. Render the customer-specific plugin with the deployed server's public MCP URL: -With interactive login, set `server.public_url` to the externally visible HTTPS -base URL and register `/auth/callback` with the identity provider. The -`auth.interactive_login` setting enables the browser-based login flow. +```bash +uv run python scripts/build_http_plugin.py \ + --url https://mcp.customer.example/mcp \ + --output ./dataiku-headless-http +``` -With direct bearer authentication, the calling application is responsible for -obtaining a correctly scoped MCP token and sending it with every request. Remove -`auth.interactive_login`; `server.public_url` is then optional. The remaining token -exchange settings are still required for DSS-backed tools. +2. Add the rendered directory to the organization's enterprise/private marketplace + or normal plugin distribution channel. Add `--zip` to the command to create + `./dataiku-headless-http.zip` when that channel accepts ZIP artifacts. +3. Users install `dataiku-headless-http`. Its setup skill completes OAuth, chooses + an administrator-managed instance, and verifies access. + +The renderer does not modify this stdio-first source checkout or copy Dataiku +credentials, OAuth secrets, or the HTTP deployment configuration. Keep only one +Dataiku MCP server enabled in each client: disable the public stdio plugin before +installing this HTTP plugin. ## Microsoft Entra ID @@ -122,36 +149,37 @@ The examples use these values: - ``: the Directory (tenant) ID. - ``: the Application (client) ID of the MCP registration. -- ``: the Application (client) ID of a DSS API registration. +- ``: the Application (client) ID of a Dataiku API registration. - ``: the HTTPS base URL of the deployed MCP server. Two app registrations are required. The **MCP registration** represents the API -that clients call and acts as the confidential client during OBO. The **DSS API +that clients call and acts as the confidential client during OBO. The **Dataiku API registration** represents the downstream resource for which Entra issues the delegated token. The current MCP server authenticates with a client secret; certificate credentials are not yet supported. Both registrations must request v2 access tokens. The MCP registration controls -the token accepted by the MCP server; the DSS API registration controls the -delegated token accepted by DSS. +the token accepted by the MCP server; the Dataiku API registration controls the +delegated token accepted by Dataiku. -### 1. Register the DSS API +### 1. Register the Dataiku API In **Microsoft Entra admin center → App registrations**: -1. Create a single-tenant registration for the DSS instance. This resource +1. Create a single-tenant registration for the Dataiku instance. This resource registration needs no redirect URI or credential. 2. Under **Expose an API**, accept the default Application ID URI - `api://` and add an enabled delegated scope named `dss.access`. -3. In this DSS API registration's Microsoft Graph app manifest, set + `api://` and add an enabled delegated scope named + `dataiku.access`. +3. In this Dataiku API registration's Microsoft Graph app manifest, set `api.requestedAccessTokenVersion` to `2`. Access-token format is controlled by the target resource registration, not by the token endpoint: leaving this value unset or setting it to `1` makes Entra issue a v1 token even when the MCP server uses the v2 OBO endpoint. 4. Record the client ID and the full scope - `api:///dss.access`. + `api:///dataiku.access`. -Use a separate registration for each DSS instance that needs a distinct token +Use a separate registration for each Dataiku instance that needs a distinct token audience. ### 2. Register the MCP server @@ -166,9 +194,9 @@ audience. URI. 5. Under **Certificates & secrets**, create a client secret and store its value securely for the MCP settings file. -6. Under **API permissions → Add a permission → My APIs**, select each DSS API and - add its delegated `dss.access` permission. This permits the MCP application to - request a DSS token on behalf of the signed-in user. +6. Under **API permissions → Add a permission → My APIs**, select each Dataiku API and + add its delegated `dataiku.access` permission. This permits the MCP application to + request a Dataiku token on behalf of the signed-in user. 7. Grant administrator consent for the tenant. See Microsoft's guidance for [exposing API scopes](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-configure-app-expose-web-apis), @@ -196,8 +224,8 @@ and the [OBO flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2- }, "dss_instances": { "prod": { - "url": "https://dss.example", - "delegated_scope": "api://replace-with-dss-app-client-id/dss.access" + "url": "https://dataiku.example", + "delegated_scope": "api://replace-with-dataiku-app-client-id/dataiku.access" } }, "user_selections": {} @@ -205,14 +233,14 @@ and the [OBO flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2- ``` `tenant_id` identifies the Entra tenant. `client_id` and `client_secret` come from -the MCP registration. For each DSS instance, `delegated_scope` is the full scope -exposed by its DSS API registration. Do not add `delegated_audience`: Entra derives +the MCP registration. For each Dataiku instance, `delegated_scope` is the full scope +exposed by its Dataiku API registration. Do not add `delegated_audience`: Entra derives the downstream resource from this scope. The server derives the tenant-specific v2 issuer, JWKS URI, token endpoint, and incoming audience. A valid incoming token has the MCP client ID as `aud` and -`mcp.access` in `scp`; the exchanged token has the DSS client ID as `aud` and -`dss.access` in `scp`. +`mcp.access` in `scp`; the exchanged token has the Dataiku client ID as `aud` and +`dataiku.access` in `scp`. For direct bearer mode, remove `interactive_login` and optionally `public_url`. The client ID and secret remain required because the server still performs OBO. @@ -222,7 +250,7 @@ The client ID and secret remain required because the server still performs OBO. Generic mode separates two concerns. First, the MCP server verifies an incoming OIDC access token. It may obtain that token through interactive login, or the caller may supply it directly. Second, a confidential exchange client trades that token -for one that DSS accepts. +for one that Dataiku accepts. The authorization server must: @@ -230,7 +258,7 @@ The authorization server must: - support an OIDC web client when interactive login is enabled; - accept an access token through [RFC 8693 token exchange](https://www.rfc-editor.org/rfc/rfc8693.html); - authenticate the exchange client with HTTP Basic client ID and secret; and -- return an access token whose issuer, audience, scope, and subject DSS trusts. +- return an access token whose issuer, audience, scope, and subject Dataiku trusts. The interactive and exchange clients may be separate registrations. Their exact setup, trust, and consent requirements depend on the authorization server. @@ -261,9 +289,9 @@ setup, trust, and consent requirements depend on the authorization server. }, "dss_instances": { "prod": { - "url": "https://dss.example", - "delegated_audience": "dss-prod", - "delegated_scope": "dss.api" + "url": "https://dataiku.example", + "delegated_audience": "dataiku-prod", + "delegated_scope": "dataiku.api" } }, "user_selections": {} @@ -272,38 +300,38 @@ setup, trust, and consent requirements depend on the authorization server. `issuer`, `jwks_uri`, `required_audience`, and `required_scope` describe the token accepted by the MCP server. `delegation` identifies the token endpoint and the -confidential client allowed to exchange it. Each DSS instance supplies the +confidential client allowed to exchange it. Each Dataiku instance supplies the `delegated_audience` and `delegated_scope` requested for its resulting token. One settings file uses one exchange endpoint and client for all instances. For direct bearer mode, remove `interactive_login` and optionally `public_url`. -`delegation` remains required for DSS-backed tools. +`delegation` remains required for Dataiku-backed tools. -## Configure DSS to trust delegated JWTs +## Configure Dataiku to trust delegated JWTs -This step is required for both exchange modes. DSS validates the delegated token +This step is required for both exchange modes. Dataiku validates the delegated token returned by the identity provider—not the original token sent to the MCP server. -Configure each DSS instance with values taken from that delegated token: +Configure each Dataiku instance with values taken from that delegated token: -| DSS setting | Purpose | +| Dataiku setting | Purpose | | --- | --- | | Issuer | Must exactly match the token's `iss` claim | | JWKS URI | Supplies the public keys used to verify its signature | | Audience | Must match the token's `aud` claim | -| Scope | Permission DSS requires from the token | +| Scope | Permission Dataiku requires from the token | | Scope claim key and format | Identifies whether the scope claim is a string or array | -| Subject match | Chooses the DSS user field compared with the token's `sub` | +| Subject match | Chooses the Dataiku user field compared with the token's `sub` | -Current DSS releases configure these values through general settings. A forthcoming -DSS release will expose the equivalent settings in the administration UI. Until -then, run the following from a Dataiku Python environment as a DSS administrator. +Current Dataiku releases configure these values through general settings. A forthcoming +Dataiku release will expose the equivalent settings in the administration UI. Until +then, run the following from a Dataiku Python environment as a Dataiku administrator. It replaces the existing global JWT settings, so record the previous value first. ```python import dataiku TENANT_ID = "replace-with-tenant-id" -DSS_APP_CLIENT_ID = "replace-with-dss-app-client-id" +DATAIKU_APP_CLIENT_ID = "replace-with-dataiku-app-client-id" ENTRA_ISSUER = f"https://login.microsoftonline.com/{TENANT_ID}/v2.0" admin = dataiku.api_client() @@ -320,8 +348,8 @@ raw["jwtAuthSettings"] = { f"https://login.microsoftonline.com/{TENANT_ID}" "/discovery/v2.0/keys" ), - "audience": DSS_APP_CLIENT_ID, - "scope": "dss.access", + "audience": DATAIKU_APP_CLIENT_ID, + "scope": "dataiku.access", "scopeClaimFormat": "STRING", "scopeClaimKey": "scp", "subjectMatch": "LOGIN", @@ -335,42 +363,13 @@ string-or-array format found in the exchanged token. > **Current subject-mapping limitation** > -> DSS always reads the standard JWT `sub` claim. `subjectMatch` only chooses whether -> that value is compared with the DSS user's login (`LOGIN`) or email (`EMAIL`); it +> Dataiku always reads the standard JWT `sub` claim. `subjectMatch` only chooses whether +> that value is compared with the Dataiku user's login (`LOGIN`) or email (`EMAIL`); it > cannot select another JWT claim. This remains true for the forthcoming UI. > > Entra uses a pairwise, application-specific `sub`, so it normally differs from a -> person's login and email. The MCP and DSS tokens may also contain different `sub` +> person's login and email. The MCP and Dataiku tokens may also contain different `sub` > values for the same user because they target different applications. For now, the -> delegated token's `sub` must equal the selected DSS login or email. Validate this +> delegated token's `sub` must equal the selected Dataiku login or email. Validate this > constraint before production rollout. See Microsoft's > [access-token claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference). - -## Verify and troubleshoot - -After starting the server: - -1. Authenticate and call `list_instances`. -2. Call `switch_instance` for one catalog entry. -3. Call a DSS-backed tool such as `list_projects`. - -| Failure | Where it occurred | Check | -| --- | --- | --- | -| MCP returns 401 | Incoming-token validation | Signature, expiry, issuer, MCP audience, and required scope | -| Token exchange returns 400 | Identity-provider token endpoint | Exchange credentials, incoming audience, downstream scope, trust, and consent | -| DSS rejects the token | DSS JWT validation or user lookup | Delegated issuer, JWKS, audience, scope claim/format, and `sub` mapping | - -For Entra, a token whose issuer is `https://sts.windows.net//` is a v1 -token. Calling a v2 token endpoint does not override the version selected by the -target resource registration, so set `api.requestedAccessTokenVersion` to `2` on -both app registrations: - -- If the MCP server rejects the incoming token, check the **MCP registration**. -- If DSS rejects the exchanged token, check the **DSS API registration identified - by that instance's `delegated_scope`**. - -After correcting the DSS API registration, the next DSS-backed tool call performs -a new exchange; the MCP server does not need to be restarted. - -Do not paste bearer tokens into tickets or logs. Use decoded claims without the -encoded token when diagnosing issuer, audience, scope, or subject mismatches. diff --git a/scripts/build_http_plugin.py b/scripts/build_http_plugin.py new file mode 100644 index 00000000..27ad7870 --- /dev/null +++ b/scripts/build_http_plugin.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Render the HTTP Dataiku Headless plugin variant from the stdio source tree.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import tempfile +from pathlib import Path +from urllib.parse import urlsplit + + +ROOT = Path(__file__).resolve().parents[1] +ASSETS = ROOT / "scripts" / "http_plugin_assets" +PLUGIN_NAME = "dataiku-headless-http" +COPY_PATHS = ( + "LICENSE", + "skills", + "docs/assets", + ".mcp.json", + ".codex-plugin", + ".claude-plugin", +) + + +def validate_url(value: str) -> str: + """Return a safe, absolute Streamable HTTP endpoint.""" + endpoint = value.strip().rstrip("/") + parsed = urlsplit(endpoint) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError("The MCP URL must be an absolute HTTPS URL.") + if parsed.username or parsed.password: + raise ValueError("The MCP URL must not contain credentials.") + if parsed.query or parsed.fragment: + raise ValueError("The MCP URL must not contain a query or fragment.") + return endpoint + + +def read_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def write_json(path: Path, value: dict) -> None: + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +def update_manifests(destination: Path, *, endpoint: str) -> None: + """Apply the HTTP-specific differences to copied source manifests.""" + mcp = read_json(destination / ".mcp.json") + mcp["mcpServers"]["dataiku"] = {"type": "http", "url": endpoint} + write_json(destination / ".mcp.json", mcp) + + codex = read_json(destination / ".codex-plugin" / "plugin.json") + codex["name"] = PLUGIN_NAME + codex["interface"]["displayName"] = "Dataiku Headless (HTTP)" + write_json(destination / ".codex-plugin" / "plugin.json", codex) + + claude = read_json(destination / ".claude-plugin" / "plugin.json") + claude["name"] = PLUGIN_NAME + claude["displayName"] = "Dataiku Headless (HTTP)" + claude["mcpServers"]["dataiku"] = {"type": "http", "url": endpoint} + write_json(destination / ".claude-plugin" / "plugin.json", claude) + + marketplace = read_json(destination / ".claude-plugin" / "marketplace.json") + marketplace["plugins"][0]["name"] = PLUGIN_NAME + write_json(destination / ".claude-plugin" / "marketplace.json", marketplace) + + +def populate_plugin(destination: Path, *, endpoint: str) -> None: + """Copy shared plugin content and overlay the HTTP-specific assets.""" + destination.mkdir(parents=True) + for relative_path in COPY_PATHS: + source = ROOT / relative_path + target = destination / relative_path + if source.is_dir(): + shutil.copytree(source, target) + else: + shutil.copy2(source, target) + + shutil.rmtree(destination / "skills" / "dataiku-headless-setup") + shutil.copytree( + ASSETS / "skills" / "dataiku-headless-setup", + destination / "skills" / "dataiku-headless-setup", + ) + + update_manifests(destination, endpoint=endpoint) + + +def render(output: Path, *, endpoint: str, archive: bool) -> Path: + """Render an HTTP plugin directory, or a ZIP containing that directory.""" + endpoint = validate_url(endpoint) + output = output.expanduser().resolve() + artifact = output.with_suffix(".zip") if archive else output + if artifact.exists(): + raise ValueError(f"Output path already exists: '{artifact}'.") + if output.exists() and archive: + raise ValueError(f"Output path already exists: '{output}'.") + + artifact.parent.mkdir(parents=True, exist_ok=True) + temporary_root = Path( + tempfile.mkdtemp(prefix=f".{output.name}-", dir=artifact.parent) + ) + staged_plugin = temporary_root / PLUGIN_NAME + try: + populate_plugin(staged_plugin, endpoint=endpoint) + if archive: + staged_archive = shutil.make_archive( + str(temporary_root / output.name), "zip", temporary_root, PLUGIN_NAME + ) + Path(staged_archive).rename(artifact) + else: + staged_plugin.rename(artifact) + finally: + shutil.rmtree(temporary_root, ignore_errors=True) + return artifact + + +def parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Render the Dataiku Headless HTTP plugin variant." + ) + parser.add_argument( + "--url", required=True, help="HTTPS Streamable HTTP MCP endpoint." + ) + parser.add_argument("--output", type=Path, required=True, help="Artifact path.") + parser.add_argument( + "--zip", action="store_true", help="Write .zip instead of a directory." + ) + return parser + + +def main() -> None: + args = parser().parse_args() + try: + artifact = render(args.output, endpoint=args.url, archive=args.zip) + except ValueError as error: + parser().error(str(error)) + print(f"Rendered {PLUGIN_NAME} at {artifact}") + + +if __name__ == "__main__": + main() diff --git a/scripts/http_plugin_assets/skills/dataiku-headless-setup/SKILL.md b/scripts/http_plugin_assets/skills/dataiku-headless-setup/SKILL.md new file mode 100644 index 00000000..5c2b8c98 --- /dev/null +++ b/scripts/http_plugin_assets/skills/dataiku-headless-setup/SKILL.md @@ -0,0 +1,22 @@ +--- +name: dataiku-headless-setup +description: Set up the organization-managed Dataiku Headless Streamable HTTP connection. Use when the user asks to install, connect, configure, or repair Dataiku Headless. Verify the remote MCP connection, complete OAuth when needed, then select and verify a Dataiku instance. +--- + +# Set Up Dataiku Headless + +Use this workflow for the customer-managed Dataiku Headless Streamable HTTP service bundled with this plugin. The workstation does not run the server. + +## Authenticate and verify access + +1. Check whether the Dataiku MCP tools are available. If they are, run `list_instances`. +2. If the tools are unavailable, or `list_instances` reports that authentication is required, explain that the remote server needs OAuth login and ask whether the user wants to authenticate now. +3. Only after the user agrees, run the matching client command: + - Codex: `codex mcp login dataiku` + - Claude Code: `claude mcp login dataiku` +4. Have the user complete the browser-based sign-in. Reload or restart the client if the tools do not reconnect, then retry `list_instances`. +5. HTTP catalogs are administrator-managed. If no instance is active, ask the user which listed instance to use and call `switch_instance`. Never call `configure_instance` or `delete_instance`. +6. Call `get_current_instance` to verify delegated Dataiku access and capture the Dataiku version when available. +7. Call `list_projects` as a lightweight read-only permission check. + +Report the active instance name and URL, Dataiku version when available, and whether the read check succeeded. For an OAuth, token-exchange, or Dataiku identity-mapping failure, report the stage and direct the user to the Dataiku Headless administrator without requesting credentials. diff --git a/skills/dataiku-headless-setup/SKILL.md b/skills/dataiku-headless-setup/SKILL.md index 17665934..8ab3041c 100644 --- a/skills/dataiku-headless-setup/SKILL.md +++ b/skills/dataiku-headless-setup/SKILL.md @@ -9,19 +9,20 @@ Bring a new or broken plugin installation to a verified Dataiku connection. A re ## Workflow -1. Check the runtime first. Run `uv --version` when local commands are available; ask the user to run it only when they are not. Dataiku Headless requires uv 0.12.0 or later. -2. If uv is missing or too old, explain briefly that it supplies the isolated Python runtime and pinned dependencies used by the local MCP server. Detect the operating system and offer the matching official Astral installer: +1. First run `list_instances`. If it succeeds, the local plugin and runtime are already available; skip directly to instance configuration and verification below (step 8). +2. If the Dataiku MCP tools are unavailable, check the runtime. Run `uv --version` when local commands are available; ask the user to run it only when they are not. Dataiku Headless requires uv 0.12.0 or later. +3. If uv is missing or too old, explain briefly that it supplies the isolated Python runtime and pinned dependencies used by the local MCP server. Detect the operating system and offer the matching official Astral installer: - macOS or Linux: `curl -LsSf https://astral.sh/uv/install.sh | sh` - Windows PowerShell: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"` -3. Obtain explicit approval, run only the selected installer, and verify with `uv --version`. Do not substitute a third-party package manager or edit shell startup files unless the user asks. -4. If the current agent process cannot see the newly installed executable, use the installer's reported location to confirm it exists, then ask the user to fully restart or reload the agent. Stop and resume setup in the new session; the already-running MCP process cannot repair its own launch environment. -5. Once uv is suitable, warm the runtime by running the server once with stdin closed. Locate the absolute path of this `SKILL.md`; the plugin root is its ancestor containing both `skills/` and `runtime/` (this file is at `/skills/dataiku-headless-setup/SKILL.md`). Do not assume the current working directory is the plugin root. +4. Obtain explicit approval, run only the selected installer, and verify with `uv --version`. Do not substitute a third-party package manager or edit shell startup files unless the user asks. +5. If the current agent process cannot see the newly installed executable, use the installer's reported location to confirm it exists, then ask the user to fully restart or reload the agent. Stop and resume setup in the new session; the already-running MCP process cannot repair its own launch environment. +6. Once uv is suitable, warm the runtime by running the server once with stdin closed. Locate the absolute path of this `SKILL.md`; the plugin root is its ancestor containing both `skills/` and `runtime/` (this file is at `/skills/dataiku-headless-setup/SKILL.md`). Do not assume the current working directory is the plugin root. - macOS or Linux: `uv run --quiet --locked --script "/runtime/run_mcp.py" --transport stdio < /dev/null` - Windows PowerShell: `$null | uv run --quiet --locked --script "\runtime\run_mcp.py" --transport stdio` 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`. -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. +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. 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 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 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/SKILL.md b/skills/dataiku-headless/SKILL.md index 020750ac..2ca49ed7 100644 --- a/skills/dataiku-headless/SKILL.md +++ b/skills/dataiku-headless/SKILL.md @@ -9,7 +9,7 @@ Use this for any Dataiku task. Choose the right reference guide first, inspect t ## 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. +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. 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. diff --git a/skills/dataiku-headless/references/cobuild.md b/skills/dataiku-headless/references/cobuild.md index 727fc47e..0c4cd4bb 100644 --- a/skills/dataiku-headless/references/cobuild.md +++ b/skills/dataiku-headless/references/cobuild.md @@ -79,7 +79,7 @@ Do not use this guide when: - Answer confirmations only with the exact current `turn_id`. Old, duplicate, and mismatched turn IDs are rejected. - Answer questions only with their exact current `turn_id` and an explicit `answers` list. Use `answers=[]` with `rejected=true` to decline. - When `rejected=true`, `answers` must be empty. -- Cobuild conversations can continue concurrently in the Dataiku UI and through MCP/API. A question or deletion confirmation is a single DSS-side action; the first channel to answer consumes it. +- Cobuild conversations can continue concurrently in the Dataiku UI and through MCP/API. A question or deletion confirmation is a single Dataiku-side action; the first channel to answer consumes it. - If an MCP answer returns "No pending question/confirmation found," it may have been answered in the UI or invalidated server-side. Do not retry; inspect the project or UI state, then continue the same conversation with a new message if appropriate. - Answer a question only when the user request or inspected context determines the answer. Otherwise, ask the user. - Honor `question.allow_multiple_answers` and `question.allow_custom_answer`; set `used_custom_answer=true` when supplying a custom free-text answer. diff --git a/tests/test_http_config.py b/tests/test_http_config.py index a0820b01..57987d73 100644 --- a/tests/test_http_config.py +++ b/tests/test_http_config.py @@ -446,9 +446,9 @@ def test_generic_oidc_http_config_example_is_valid(monkeypatch): instances, selections = http.get_instances_and_selections() assert isinstance(http._load_config(), HTTPConfig) assert set(instances) == {"prod"} - assert instances["prod"].url == "https://dss.example" - assert instances["prod"].delegated_audience == "dss-prod" - assert instances["prod"].delegated_scope == "dss.api" + assert instances["prod"].url == "https://dataiku.example" + assert instances["prod"].delegated_audience == "dataiku-prod" + assert instances["prod"].delegated_scope == "dataiku.api" assert selections == {} @@ -474,7 +474,7 @@ def test_entra_http_config_example_is_valid(monkeypatch): instances, selections = http.get_instances_and_selections() assert instances["prod"].delegated_audience == "" assert instances["prod"].delegated_scope == ( - "api://replace-with-dss-app-client-id/dss.access" + "api://replace-with-dataiku-app-client-id/dataiku.access" ) assert selections == {} diff --git a/tests/test_http_plugin_renderer.py b/tests/test_http_plugin_renderer.py new file mode 100644 index 00000000..d3aa61a2 --- /dev/null +++ b/tests/test_http_plugin_renderer.py @@ -0,0 +1,133 @@ +# 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. + +"""The HTTP plugin renderer must leave the stdio source untouched.""" + +import json +import subprocess +import sys +import zipfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "build_http_plugin.py" + + +def _run(*arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), *arguments], + check=False, + capture_output=True, + text=True, + ) + + +def _json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def test_renders_http_plugin_with_http_only_setup_skill(tmp_path): + output = tmp_path / "dataiku-headless-http" + endpoint = "https://mcp.customer.example/mcp" + source_mcp = (ROOT / ".mcp.json").read_text(encoding="utf-8") + source_codex = _json(ROOT / ".codex-plugin" / "plugin.json") + source_claude = _json(ROOT / ".claude-plugin" / "plugin.json") + + result = _run("--url", endpoint, "--output", str(output)) + + assert result.returncode == 0, result.stderr + assert _json(output / ".mcp.json")["mcpServers"]["dataiku"] == { + "type": "http", + "url": endpoint, + } + assert _json(output / ".claude-plugin" / "plugin.json")["mcpServers"][ + "dataiku" + ] == {"type": "http", "url": endpoint} + assert _json(output / ".codex-plugin" / "plugin.json")["name"] == ( + "dataiku-headless-http" + ) + assert ( + _json(output / ".claude-plugin" / "marketplace.json")["plugins"][0]["name"] + == "dataiku-headless-http" + ) + rendered_codex = _json(output / ".codex-plugin" / "plugin.json") + rendered_claude = _json(output / ".claude-plugin" / "plugin.json") + assert rendered_codex["version"] == source_codex["version"] + assert rendered_codex["description"] == source_codex["description"] + assert rendered_claude["version"] == source_claude["version"] + assert rendered_claude["description"] == source_claude["description"] + + setup = (output / "skills" / "dataiku-headless-setup" / "SKILL.md").read_text( + encoding="utf-8" + ) + expected_setup = ( + ROOT + / "scripts" + / "http_plugin_assets" + / "skills" + / "dataiku-headless-setup" + / "SKILL.md" + ).read_text(encoding="utf-8") + assert setup == expected_setup + assert "ask whether the user wants to authenticate now" in setup + assert "codex mcp login dataiku" in setup + assert "claude mcp login dataiku" in setup + assert "retry `list_instances`" in setup + assert "uv" not in setup + assert "API key" not in setup + assert "Never call `configure_instance` or `delete_instance`" in setup + assert not (output / "skills" / "dataiku-headless-setup" / "references").exists() + assert (ROOT / ".mcp.json").read_text(encoding="utf-8") == source_mcp + + +def test_renders_zip_with_one_plugin_root(tmp_path): + output = tmp_path / "dataiku-headless-http" + + result = _run( + "--url", + "https://mcp.customer.example/mcp", + "--output", + str(output), + "--zip", + ) + + assert result.returncode == 0, result.stderr + archive = output.with_suffix(".zip") + assert archive.is_file() + assert not output.exists() + with zipfile.ZipFile(archive) as bundle: + assert "dataiku-headless-http/.mcp.json" in bundle.namelist() + assert "dataiku-headless-http/skills/dataiku-headless-setup/SKILL.md" in ( + bundle.namelist() + ) + + +def test_rejects_invalid_url_and_existing_artifact(tmp_path): + output = tmp_path / "dataiku-headless-http" + + invalid = _run("--url", "http://mcp.customer.example/mcp", "--output", str(output)) + assert invalid.returncode == 2 + assert "absolute HTTPS URL" in invalid.stderr + assert not output.exists() + + output.mkdir() + marker = output / "preserve" + marker.write_text("keep", encoding="utf-8") + existing = _run( + "--url", "https://mcp.customer.example/mcp", "--output", str(output) + ) + assert existing.returncode == 2 + assert "already exists" in existing.stderr + assert marker.read_text(encoding="utf-8") == "keep" diff --git a/tests/test_launcher_cli.py b/tests/test_launcher_cli.py index cc6d747b..29f7c08b 100644 --- a/tests/test_launcher_cli.py +++ b/tests/test_launcher_cli.py @@ -23,7 +23,7 @@ import pytest SCRIPT = Path(__file__).resolve().parent.parent / "runtime" / "run_mcp.py" -SETUP_SKILL = ( +STDIO_SETUP_REFERENCE = ( Path(__file__).resolve().parent.parent / "skills" / "dataiku-headless-setup" @@ -121,9 +121,17 @@ def test_launcher_help_does_not_import_the_server(): def test_setup_skill_warmup_commands_select_stdio_transport(): commands = [ line - for line in SETUP_SKILL.read_text(encoding="utf-8").splitlines() + for line in STDIO_SETUP_REFERENCE.read_text(encoding="utf-8").splitlines() if "uv run " in line and "run_mcp.py" in line ] assert len(commands) == 2 assert all("--transport stdio" in command for command in commands) + + +def test_setup_skill_checks_mcp_before_uv(): + setup_skill = STDIO_SETUP_REFERENCE.read_text(encoding="utf-8") + + assert setup_skill.index("First run `list_instances`.") < setup_skill.index( + "`uv --version`" + ) diff --git a/tests/test_plugin_packaging.py b/tests/test_plugin_packaging.py new file mode 100644 index 00000000..72cda4c4 --- /dev/null +++ b/tests/test_plugin_packaging.py @@ -0,0 +1,57 @@ +# 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. + +"""Packaging invariants for the public stdio plugin.""" + +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _json(path: str) -> dict: + return json.loads((ROOT / path).read_text(encoding="utf-8")) + + +def test_marketplace_plugin_uses_stdio(): + claude = _json(".claude-plugin/plugin.json") + bundled_mcp = _json(".mcp.json") + + assert claude["mcpServers"]["dataiku"]["type"] == "stdio" + assert bundled_mcp["mcpServers"]["dataiku"]["args"][-2:] == [ + "--transport", + "stdio", + ] + + +def test_marketplace_entry_matches_plugin_identity(): + claude = _json(".claude-plugin/plugin.json") + marketplace = _json(".claude-plugin/marketplace.json") + + entries = [ + entry for entry in marketplace["plugins"] if entry["name"] == claude["name"] + ] + assert len(entries) == 1 + assert entries[0]["source"] == "./" + + +def test_public_setup_skill_is_stdio_only(): + setup_skill = ( + ROOT / "skills" / "dataiku-headless-setup" / "SKILL.md" + ).read_text(encoding="utf-8") + + assert "--transport stdio" in setup_skill + assert "references/http.md" not in setup_skill + assert "customer-managed HTTP" not in setup_skill