diff --git a/.github/evals/static_checks.py b/.github/evals/static_checks.py index b5350e2..9ebce72 100644 --- a/.github/evals/static_checks.py +++ b/.github/evals/static_checks.py @@ -560,6 +560,48 @@ def check_version_consistency(repo_root): return failures +# --------------------------------------------------------------------------- +# CAT-7 auth.py _ALLOWED_SKILLS sync +# --------------------------------------------------------------------------- + + +def check_allowed_skills_sync(repo_root, all_skill_names): + """ + EVAL-SKILLS-SYNC-01: _ALLOWED_SKILLS in auth.py must contain every skill + directory name plus "unknown", and no stale entries. + """ + failures = [] + auth_path = repo_root / ".github" / "plugins" / "dataverse" / "scripts" / "auth.py" + if not auth_path.exists(): + return failures + + text = auth_path.read_text(encoding="utf-8") + match = re.search(r"_ALLOWED_SKILLS\s*=\s*frozenset\(\{([^}]+)\}\)", text) + if not match: + failures.append("EVAL-SKILLS-SYNC-01 could not parse _ALLOWED_SKILLS from auth.py") + return failures + + allowed = {s.strip().strip('"').strip("'") for s in match.group(1).split(",")} + allowed.discard("") + expected = all_skill_names | {"unknown"} + + missing = expected - allowed + extra = allowed - expected + + if missing: + failures.append( + f"EVAL-SKILLS-SYNC-01 _ALLOWED_SKILLS is missing skill(s): {sorted(missing)}. " + f"Add them to auth.py." + ) + if extra: + failures.append( + f"EVAL-SKILLS-SYNC-01 _ALLOWED_SKILLS has stale entries: {sorted(extra)}. " + f"Remove them from auth.py or add the skill directory." + ) + + return failures + + # --------------------------------------------------------------------------- # CAT-8 Skill Token Budget (Anthropic Skills spec) # --------------------------------------------------------------------------- @@ -662,6 +704,9 @@ def main(): repo_root = skills_dir.parent.parent.parent.parent all_failures.extend(check_version_consistency(repo_root)) + # auth.py _ALLOWED_SKILLS sync — check against actual skill directories + all_failures.extend(check_allowed_skills_sync(repo_root, all_skill_names)) + if all_failures: # Group output by category prefix for readability categories = {} diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 761da28..1ccca89 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -2,7 +2,7 @@ "name": "dataverse-skills", "metadata": { "description": "Official Dataverse plugin marketplace for agent-guided development", - "version": "1.4.5" + "version": "1.5.0" }, "owner": { "name": "Microsoft", @@ -13,7 +13,7 @@ "name": "dataverse", "description": "Agent skills for building on, analyzing, and managing Microsoft Dataverse — with Dataverse MCP, PAC CLI, and Python SDK.", "source": "./.github/plugins/dataverse", - "version": "1.4.5", + "version": "1.5.0", "homepage": "https://github.com/microsoft/Dataverse-skills" } ] diff --git a/.github/plugins/dataverse/.claude-plugin/plugin.json b/.github/plugins/dataverse/.claude-plugin/plugin.json index a6afde0..4eb3e97 100644 --- a/.github/plugins/dataverse/.claude-plugin/plugin.json +++ b/.github/plugins/dataverse/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "dataverse", "description": "Agent skills for building on, analyzing, and managing Microsoft Dataverse — with Dataverse MCP, PAC CLI, and Python SDK.", - "version": "1.4.5", + "version": "1.5.0", "author": { "name": "Microsoft", "url": "https://www.microsoft.com" diff --git a/.github/plugins/dataverse/.github/plugin/plugin.json b/.github/plugins/dataverse/.github/plugin/plugin.json index 10b08ce..ee9d107 100644 --- a/.github/plugins/dataverse/.github/plugin/plugin.json +++ b/.github/plugins/dataverse/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "dataverse", "description": "Agent skills for building on, analyzing, and managing Microsoft Dataverse — with Dataverse MCP, PAC CLI, and Python SDK.", - "version": "1.4.5", + "version": "1.5.0", "author": { "name": "Microsoft", "url": "https://www.microsoft.com" diff --git a/.github/plugins/dataverse/scripts/auth.py b/.github/plugins/dataverse/scripts/auth.py index b844f64..ac091d2 100644 --- a/.github/plugins/dataverse/scripts/auth.py +++ b/.github/plugins/dataverse/scripts/auth.py @@ -14,19 +14,18 @@ Functions: load_env() — loads .env into os.environ - get_credential() — returns a TokenCredential for use with DataverseClient + get_client(skill) — returns a DataverseClient with plugin attribution get_token(scope=None) — returns a raw access token string + get_plugin_headers(skill, token) — returns headers dict for raw Web API calls Usage: - # PREFERRED — use the Python SDK for all supported operations: - from auth import get_credential, load_env - from PowerPlatform.Dataverse.client import DataverseClient - load_env() - client = DataverseClient(os.environ["DATAVERSE_URL"], get_credential()) + # PREFERRED — SDK with plugin attribution: + from auth import get_client + client = get_client("dv-data") - # ONLY for operations the SDK does NOT support (forms, views, $ref, $apply): - from auth import get_token, load_env - token = get_token() + # Raw Web API only (forms, views, $ref, $apply): + from auth import get_token, get_plugin_headers + headers = get_plugin_headers("dv-metadata", get_token()) Reads from .env in the repo root (parent of scripts/) or current working directory: DATAVERSE_URL — required @@ -36,6 +35,7 @@ """ import os +import re import sys from pathlib import Path @@ -66,7 +66,7 @@ def load_env(): _credential = None -def get_credential(): +def _get_credential(): """ Return an Azure Identity TokenCredential, creating one on first call. @@ -165,7 +165,7 @@ def get_token(scope=None): if not scope: scope = f"{dataverse_url}/.default" - credential = get_credential() + credential = _get_credential() try: from azure.identity import DeviceCodeCredential @@ -188,6 +188,111 @@ def get_token(scope=None): return token.token +_ALLOWED_SKILLS = frozenset({ + "dv-overview", "dv-connect", "dv-data", "dv-query", + "dv-metadata", "dv-solution", "dv-admin", "dv-security", + "unknown", +}) +_ALLOWED_AGENTS = frozenset({ + "claude-code", "copilot", "cursor", "codex", "unknown", +}) +# Strict format: key=value pairs, semicolon-separated. No spaces, no PII. +_CONTEXT_RE = re.compile( + r"^[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+(;[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+)*$" +) + + +def _plugin_version(): + """Read plugin version from .env (set by dv-connect at setup time).""" + return os.environ.get("DATAVERSE_PLUGIN_VERSION", "unknown") + + +def _current_agent(): + agent = os.environ.get("DATAVERSE_PLUGIN_AGENT", "unknown") + if agent not in _ALLOWED_AGENTS: + raise ValueError(f"Unknown agent '{agent}'; allowed: {_ALLOWED_AGENTS}") + return agent + + +def _validate_skill(skill): + if skill not in _ALLOWED_SKILLS: + raise ValueError(f"Unknown skill '{skill}'; allowed: {_ALLOWED_SKILLS}") + return skill + + +def _build_operation_context(skill): + """Build and validate the operation_context string. + + Returns an OperationContext object for the SDK. The string is validated + both here (via allowlists) and inside OperationContext.__post_init__ + (via regex + control-char check). + + SECURITY: Only closed-schema values from _ALLOWED_SKILLS and + _ALLOWED_AGENTS are used. Never pass user-provided or free-form + strings into operation_context — it is written to HTTP headers and + server-side telemetry logs. + """ + ctx_str = f"app=dataverse-skills/{_plugin_version()};skill={skill};agent={_current_agent()}" + if not _CONTEXT_RE.match(ctx_str): + raise ValueError( + f"operation_context failed format validation: {ctx_str!r}. " + "Must be semicolon-separated key=value pairs with no spaces or special characters." + ) + from PowerPlatform.Dataverse.core.config import OperationContext + return OperationContext(user_agent_context=ctx_str) + + +def get_client(skill, **kwargs): + """Return a DataverseClient with plugin attribution baked in. + + The operation_context is appended to the User-Agent header as a + parenthesized comment for server-side traffic attribution. + + IMPORTANT: Do not modify the operation_context — it uses a closed + schema (app/skill/agent) for safe server-side attribution. Never + include secrets, PII, or free-form text. + + :param skill: Skill name (e.g. "dv-data", "dv-query"). + :param kwargs: Extra keyword arguments forwarded to DataverseClient. + :returns: Configured DataverseClient instance. + """ + load_env() + _validate_skill(skill) + from PowerPlatform.Dataverse.client import DataverseClient + return DataverseClient( + base_url=os.environ["DATAVERSE_URL"], + credential=_get_credential(), + context=_build_operation_context(skill), + **kwargs, + ) + + +def get_plugin_headers(skill, token=None): + """Return HTTP headers for raw Web API calls, with plugin attribution. + + Use this for operations the SDK does not support (forms, views, $apply, + N:N $expand, unbound actions). + + IMPORTANT: Do not modify the User-Agent context — it uses a closed + schema (app/skill/agent) for safe server-side attribution. Never + include secrets, PII, or free-form text. + + :param skill: Skill name (e.g. "dv-metadata"). + :param token: Optional bearer token (from get_token()). + :returns: Headers dict with User-Agent and optional Authorization. + """ + _validate_skill(skill) + ctx_str = f"app=dataverse-skills/{_plugin_version()};skill={skill};agent={_current_agent()}" + if not _CONTEXT_RE.match(ctx_str): + raise ValueError( + f"operation_context failed format validation: {ctx_str!r}." + ) + headers = {"User-Agent": f"Python-urllib ({ctx_str})"} + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + if __name__ == "__main__": token = get_token() print(token) diff --git a/.github/plugins/dataverse/scripts/enable-mcp-client.py b/.github/plugins/dataverse/scripts/enable-mcp-client.py index d083d28..9744cd2 100644 --- a/.github/plugins/dataverse/scripts/enable-mcp-client.py +++ b/.github/plugins/dataverse/scripts/enable-mcp-client.py @@ -14,7 +14,7 @@ import os sys.path.insert(0, os.path.dirname(__file__)) -from auth import get_credential, load_env +from auth import get_client def find_client(client, app_id): @@ -29,20 +29,13 @@ def find_client(client, app_id): def main(): - load_env() - env_url = os.environ.get("DATAVERSE_URL", "").rstrip("/") + client = get_client("dv-connect") mcp_client_id = os.environ.get("MCP_CLIENT_ID") - if not env_url: - print("ERROR: DATAVERSE_URL not set in .env", flush=True) - sys.exit(1) if not mcp_client_id: print("ERROR: MCP_CLIENT_ID not set in .env", flush=True) sys.exit(1) - from PowerPlatform.Dataverse.client import DataverseClient - client = DataverseClient(base_url=env_url, credential=get_credential()) - print(f"Looking up MCP client {mcp_client_id}...", flush=True) record = find_client(client, mcp_client_id) diff --git a/.github/plugins/dataverse/skills/dv-admin/references/orgdb-settings.md b/.github/plugins/dataverse/skills/dv-admin/references/orgdb-settings.md index 061c441..c8c62cb 100644 --- a/.github/plugins/dataverse/skills/dv-admin/references/orgdb-settings.md +++ b/.github/plugins/dataverse/skills/dv-admin/references/orgdb-settings.md @@ -17,15 +17,17 @@ Settings like search mode, MCP, copilot features, fabric, and retention live ins import os, sys, json, urllib.request from xml.etree import ElementTree as ET sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_token, load_env # SDK does not support orgdborgsettings XML blob +from auth import get_token, get_plugin_headers, load_env # SDK does not support orgdborgsettings XML blob load_env() env_url = os.environ["DATAVERSE_URL"].rstrip("/") token = get_token() +_headers = get_plugin_headers("dv-admin", token) +_headers["Accept"] = "application/json" req = urllib.request.Request( f"{env_url}/api/data/v9.2/organizations?$select=organizationid,orgdborgsettings", - headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + headers=_headers, ) with urllib.request.urlopen(req) as resp: org = json.loads(resp.read())["value"][0] @@ -41,7 +43,7 @@ for child in sorted(root, key=lambda c: c.tag): import os, sys, json, urllib.request, urllib.error from xml.etree import ElementTree as ET sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_token, load_env # SDK does not support orgdborgsettings XML blob +from auth import get_token, get_plugin_headers, load_env # SDK does not support orgdborgsettings XML blob load_env() env_url = os.environ["DATAVERSE_URL"].rstrip("/") @@ -50,13 +52,13 @@ token = get_token() SETTING_NAME = "SearchAndCopilotIndexMode" # PascalCase, case-sensitive SETTING_VALUE = "0" # always a string in XML -headers = { - "Authorization": f"Bearer {token}", +headers = get_plugin_headers("dv-admin", token) +headers.update({ "Accept": "application/json", "Content-Type": "application/json", "OData-MaxVersion": "4.0", "OData-Version": "4.0", -} +}) # Fetch current XML req = urllib.request.Request( diff --git a/.github/plugins/dataverse/skills/dv-admin/references/recycle-bin.md b/.github/plugins/dataverse/skills/dv-admin/references/recycle-bin.md index 31b0591..f6cae26 100644 --- a/.github/plugins/dataverse/skills/dv-admin/references/recycle-bin.md +++ b/.github/plugins/dataverse/skills/dv-admin/references/recycle-bin.md @@ -9,7 +9,7 @@ Recycle bin settings live in the `recyclebinconfigs` entity, NOT in `orgdborgset ```python import os, sys, json, urllib.request, urllib.parse sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_token, load_env # SDK does not support recyclebinconfigs entity +from auth import get_token, get_plugin_headers, load_env # SDK does not support recyclebinconfigs entity load_env() env_url = os.environ["DATAVERSE_URL"].rstrip("/") @@ -17,13 +17,13 @@ token = get_token() ORGANIZATION_ENTITY_ID = "e1bd1119-6e9d-45a4-bc15-12051e65a0bd" -headers = { - "Authorization": f"Bearer {token}", +headers = get_plugin_headers("dv-admin", token) +headers.update({ "Accept": "application/json", "Content-Type": "application/json", "OData-MaxVersion": "4.0", "OData-Version": "4.0", -} +}) # Fetch org-level config by extensionofrecordid (NOT by name) filter_q = urllib.parse.quote(f"_extensionofrecordid_value eq '{ORGANIZATION_ENTITY_ID}'") diff --git a/.github/plugins/dataverse/skills/dv-admin/references/settings-overrides.md b/.github/plugins/dataverse/skills/dv-admin/references/settings-overrides.md index d08dd32..f0c3d54 100644 --- a/.github/plugins/dataverse/skills/dv-admin/references/settings-overrides.md +++ b/.github/plugins/dataverse/skills/dv-admin/references/settings-overrides.md @@ -14,17 +14,18 @@ Allowlisted uniquenames (both `datatype=2` bool, stored as string `"true"`/`"fal ```python import os, sys, json, urllib.request, urllib.parse sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_token, load_env # SDK does not support settingdefinition/organizationsettings entities +from auth import get_token, get_plugin_headers, load_env # SDK does not support settingdefinition/organizationsettings entities load_env() env_url = os.environ["DATAVERSE_URL"].rstrip("/") -headers = { - "Authorization": f"Bearer {get_token()}", +token = get_token() +headers = get_plugin_headers("dv-admin", token) +headers.update({ "Accept": "application/json", "OData-MaxVersion": "4.0", "OData-Version": "4.0", "Content-Type": "application/json", -} +}) UNIQUENAME = "PowerAppsAppLevelSecurityRolesEnabled" # or PlanShareSecurityRolesEnabled diff --git a/.github/plugins/dataverse/skills/dv-connect/SKILL.md b/.github/plugins/dataverse/skills/dv-connect/SKILL.md index 668a797..ad6a0b1 100644 --- a/.github/plugins/dataverse/skills/dv-connect/SKILL.md +++ b/.github/plugins/dataverse/skills/dv-connect/SKILL.md @@ -128,11 +128,23 @@ Detect the current tool (Claude or Copilot) from context and set `MCP_CLIENT_ID` - Claude (CLI or VSCode extension): `0c412cc3-0dd6-449b-987f-05b053db9457` - GitHub Copilot: `aebc6443-996d-45c2-90f0-388ff96faa56` +Also set plugin attribution variables for User-Agent tagging. **Fill in the two literals below from your own context** — you (the agent) loaded this plugin, so you already know both values: + +- `PLUGIN_VERSION` — the `version` field of your loaded plugin manifest (e.g. `"1.5.0"`). At runtime, `auth.py` re-reads this from the live manifest via host env vars; this `.env` entry is a fallback for offline cases. +- `AGENT` — your host identity, one of: `claude-code`, `copilot`, `cursor`, `codex`, or `unknown`. Must match an entry in `_ALLOWED_AGENTS` in `auth.py` — if you don't recognize your host, use `unknown`. + ```python +# Substitute these two literals from your loaded plugin context. +# Do NOT leave the angle-bracket placeholders — replace with real values. +plugin_version = "" +agent_host = "" + with open(".env", "w") as f: f.write(f"DATAVERSE_URL={dataverse_url}\n") f.write(f"TENANT_ID={tenant_id}\n") f.write(f"MCP_CLIENT_ID={mcp_client_id}\n") + f.write(f"DATAVERSE_PLUGIN_VERSION={plugin_version}\n") + f.write(f"DATAVERSE_PLUGIN_AGENT={agent_host}\n") f.write(f"SOLUTION_NAME={solution_name}\n") f.write(f"PUBLISHER_PREFIX=\n") # filled in when solution is created f.write(f"PAC_AUTH_PROFILE=nonprod\n") @@ -175,7 +187,7 @@ mkdir -p solutions plugins scripts Copy plugin scripts: ``` -cp .dataverse/scripts/auth.py scripts/ +cp .github/plugins/dataverse/scripts/auth.py scripts/ ``` Copy `templates/CLAUDE.md` to the repo root if it doesn't exist. Replace placeholders (`{{DATAVERSE_URL}}`, `{{SOLUTION_NAME}}`, `{{PUBLISHER_PREFIX}}`) with values from `.env`. @@ -214,6 +226,14 @@ If MCP is not configured, follow [mcp-configuration.md](references/mcp-configura 5. Register the MCP server (Copilot: write JSON config; Claude: run `claude mcp add` command) 6. Handle admin consent and environment allowlist (one-time per tenant/environment) +**Plugin attribution for MCP:** This plugin uses the **stdio proxy** transport (`npx @microsoft/dataverse mcp `) — the CLI runs as a local subprocess and proxies requests to the Dataverse MCP HTTP endpoint. When registering the stdio proxy, include `DATAVERSE_OPERATION_CONTEXT` in the env block so the CLI reads it at startup and appends it to its User-Agent on all outbound HTTP requests to `/api/mcp`. Build the value from `.env`: + +``` +DATAVERSE_OPERATION_CONTEXT=app=dataverse-skills/{DATAVERSE_PLUGIN_VERSION};skill=unknown;agent={DATAVERSE_PLUGIN_AGENT} +``` + +For Claude Code (`claude mcp add -t stdio`), pass it via `-e DATAVERSE_OPERATION_CONTEXT=...`. For Copilot/Cursor JSON configs, add it to the `"env"` object in the stdio server entry. + **Important:** MCP configuration requires an editor/CLI restart. **For Copilot:** Write the JSON config, then: diff --git a/.github/plugins/dataverse/skills/dv-data/SKILL.md b/.github/plugins/dataverse/skills/dv-data/SKILL.md index b07bf98..e97209b 100644 --- a/.github/plugins/dataverse/skills/dv-data/SKILL.md +++ b/.github/plugins/dataverse/skills/dv-data/SKILL.md @@ -31,10 +31,9 @@ Use the official Microsoft Power Platform Dataverse Client Python SDK for all da **If an operation is in the "supports" list below, you MUST use the SDK — not `urllib`, `requests`, or raw HTTP.** -**Correct imports** (always preceded by `sys.path.insert` in a full script — see Setup below): +**Correct import** (always preceded by `sys.path.insert` in a full script — see Setup below): ``` -from auth import get_credential, load_env -from PowerPlatform.Dataverse.client import DataverseClient +from auth import get_client ``` **WRONG for SDK-supported operations:** @@ -75,17 +74,15 @@ Use raw Web API (`get_token()`) for: ```python import os, sys sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_credential, load_env -from PowerPlatform.Dataverse.client import DataverseClient - -load_env() -client = DataverseClient( - base_url=os.environ["DATAVERSE_URL"], - credential=get_credential(), -) +from auth import get_client + +# get_client sets a plugin attribution context on the User-Agent header. +# Do not modify the context value — it is a closed schema for server-side +# telemetry (app/skill/agent). Never include secrets or PII. +client = get_client("dv-data") ``` -`get_credential()` returns `ClientSecretCredential` (if CLIENT_ID + CLIENT_SECRET are in `.env`) or `DeviceCodeCredential` (interactive fallback). See `scripts/auth.py`. +`get_client(skill)` handles auth, environment URL, and plugin attribution (User-Agent tagging). See `scripts/auth.py`. For scripts that run to completion: wrap in `with DataverseClient(...) as client:` for automatic connection cleanup (recommended since b6). For notebooks and interactive sessions, the explicit client above is simpler. @@ -234,11 +231,12 @@ client.records.upsert("account", [ ```python import csv, os, sys sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_credential, load_env -from PowerPlatform.Dataverse.client import DataverseClient +from auth import get_client -load_env() -client = DataverseClient(base_url=os.environ["DATAVERSE_URL"], credential=get_credential()) +# get_client sets a plugin attribution context on the User-Agent header. +# Do not modify the context value — it is a closed schema for server-side +# telemetry (app/skill/agent). Never include secrets or PII. +client = get_client("dv-data") with open("data/customers.csv", newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) diff --git a/.github/plugins/dataverse/skills/dv-data/references/multi-table-fk-import.md b/.github/plugins/dataverse/skills/dv-data/references/multi-table-fk-import.md index d81a504..eedfba0 100644 --- a/.github/plugins/dataverse/skills/dv-data/references/multi-table-fk-import.md +++ b/.github/plugins/dataverse/skills/dv-data/references/multi-table-fk-import.md @@ -16,14 +16,15 @@ Using upsert from the start means partial failures, retries, and re-runs never c ```python import os, sys, csv, time sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_credential, load_env -from PowerPlatform.Dataverse.client import DataverseClient +from auth import get_client from PowerPlatform.Dataverse.models.upsert import UpsertItem from PowerPlatform.Dataverse.core.errors import HttpError from concurrent.futures import ThreadPoolExecutor, as_completed -load_env() -client = DataverseClient(base_url=os.environ["DATAVERSE_URL"], credential=get_credential()) +# get_client sets a plugin attribution context on the User-Agent header. +# Do not modify the context value — it is a closed schema for server-side +# telemetry (app/skill/agent). Never include secrets or PII. +client = get_client("dv-data") def bind(entity_set, guid): """Build an @odata.bind value. entity_set must be the actual EntitySetName, not a guess.""" diff --git a/.github/plugins/dataverse/skills/dv-data/references/sample-data-generation.md b/.github/plugins/dataverse/skills/dv-data/references/sample-data-generation.md index 6402a5b..badbca1 100644 --- a/.github/plugins/dataverse/skills/dv-data/references/sample-data-generation.md +++ b/.github/plugins/dataverse/skills/dv-data/references/sample-data-generation.md @@ -23,19 +23,22 @@ Use the EntityDefinitions metadata API to discover required columns and their ty ```python import os, sys, json, urllib.request, urllib.parse sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_token, load_env # SDK does not support EntityDefinitions metadata +from auth import get_token, get_plugin_headers, load_env # SDK does not support EntityDefinitions metadata load_env() env_url = os.environ["DATAVERSE_URL"].rstrip("/") +token = get_token() TABLE = "account" # or any other table logical name params = urllib.parse.urlencode({ "$select": "LogicalName,AttributeType,RequiredLevel,DisplayName", "$filter": "AttributeOf eq null", }) +_headers = get_plugin_headers("dv-data", token) +_headers["Accept"] = "application/json" req = urllib.request.Request( f"{env_url}/api/data/v9.2/EntityDefinitions(LogicalName='{TABLE}')/Attributes?{params}", - headers={"Authorization": f"Bearer {get_token()}", "Accept": "application/json"}, + headers=_headers, ) with urllib.request.urlopen(req) as resp: attrs = json.loads(resp.read())["value"] @@ -70,12 +73,12 @@ This template is **table-agnostic by design**. It reads the `attrs` list from St ```python import os, sys, random, datetime sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_credential, load_env -from PowerPlatform.Dataverse.client import DataverseClient +from auth import get_client -load_env() -env_url = os.environ["DATAVERSE_URL"].rstrip("/") -client = DataverseClient(base_url=env_url, credential=get_credential()) +# get_client sets a plugin attribution context on the User-Agent header. +# Do not modify the context value — it is a closed schema for server-side +# telemetry (app/skill/agent). Never include secrets or PII. +client = get_client("dv-data") TABLE = "account" # any table logical name from Step 1 COUNT = 5 # confirmed with user diff --git a/.github/plugins/dataverse/skills/dv-metadata/SKILL.md b/.github/plugins/dataverse/skills/dv-metadata/SKILL.md index f2f7803..6b45cfc 100644 --- a/.github/plugins/dataverse/skills/dv-metadata/SKILL.md +++ b/.github/plugins/dataverse/skills/dv-metadata/SKILL.md @@ -83,11 +83,12 @@ The only time you write files directly is when editing something that already ex ```python import os, sys sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_credential, load_env -from PowerPlatform.Dataverse.client import DataverseClient +from auth import get_client -load_env() -client = DataverseClient(os.environ["DATAVERSE_URL"], get_credential()) +# get_client sets a plugin attribution context on the User-Agent header. +# Do not modify the context value — it is a closed schema for server-side +# telemetry (app/skill/agent). Never include secrets or PII. +client = get_client("dv-metadata") info = client.tables.create( "new_ProjectBudget", diff --git a/.github/plugins/dataverse/skills/dv-metadata/references/alternate-keys.md b/.github/plugins/dataverse/skills/dv-metadata/references/alternate-keys.md index 02c29ea..71a5083 100644 --- a/.github/plugins/dataverse/skills/dv-metadata/references/alternate-keys.md +++ b/.github/plugins/dataverse/skills/dv-metadata/references/alternate-keys.md @@ -16,11 +16,12 @@ An alternate key tells Dataverse how to uniquely identify a record using a busin ```python import os, sys sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_credential, load_env -from PowerPlatform.Dataverse.client import DataverseClient +from auth import get_client -load_env() -client = DataverseClient(os.environ["DATAVERSE_URL"], get_credential()) +# get_client sets a plugin attribution context on the User-Agent header. +# Do not modify the context value — it is a closed schema for server-side +# telemetry (app/skill/agent). Never include secrets or PII. +client = get_client("dv-metadata") # Single-column key (most common for imports) key = client.tables.create_alternate_key( diff --git a/.github/plugins/dataverse/skills/dv-metadata/references/forms-and-views.md b/.github/plugins/dataverse/skills/dv-metadata/references/forms-and-views.md index 9c0454c..6512b19 100644 --- a/.github/plugins/dataverse/skills/dv-metadata/references/forms-and-views.md +++ b/.github/plugins/dataverse/skills/dv-metadata/references/forms-and-views.md @@ -8,11 +8,13 @@ Neither the MCP server nor the Python SDK supports forms or views. Use the Web A # POST /api/data/v9.2/systemforms import os, sys, json, urllib.request sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_token, load_env # get_token() is correct here — SDK does not support forms +from auth import get_token, get_plugin_headers, load_env # get_token + get_plugin_headers — SDK does not support forms load_env() env = os.environ["DATAVERSE_URL"].rstrip("/") token = get_token() +_headers = get_plugin_headers("dv-metadata", token) +_headers.update({"Content-Type": "application/json", "OData-MaxVersion": "4.0", "OData-Version": "4.0"}) form_xml = """
@@ -50,10 +52,7 @@ body = { req = urllib.request.Request( f"{env}/api/data/v9.2/systemforms", data=json.dumps(body).encode(), - headers={"Authorization": f"Bearer {token}", - "Content-Type": "application/json", - "OData-MaxVersion": "4.0", - "OData-Version": "4.0"}, + headers=_headers, method="POST" ) with urllib.request.urlopen(req) as resp: diff --git a/.github/plugins/dataverse/skills/dv-overview/SKILL.md b/.github/plugins/dataverse/skills/dv-overview/SKILL.md index 048842c..49b05a0 100644 --- a/.github/plugins/dataverse/skills/dv-overview/SKILL.md +++ b/.github/plugins/dataverse/skills/dv-overview/SKILL.md @@ -66,7 +66,7 @@ Examples where MCP is sufficient: "how many accounts have 'jeff' in the name?", - Creating tables, columns, relationships? → `client.tables.create()`, `.add_columns()`, `.create_lookup_field()` — see `dv-metadata` - Creating publishers or solutions? → `client.records.create("publisher", {...})`, `client.records.create("solution", {...})` — see `dv-solution` -**Before using `from auth import get_token` or `import requests`:** check whether the operation is in the Raw Web API list below. If it is not in that list — the SDK supports it — use `from auth import get_credential` + `DataverseClient` instead. Using raw HTTP for SDK-supported operations is the most common off-rails mistake. +**Before using `from auth import get_token` or `import requests`:** check whether the operation is in the Raw Web API list below. If it is not in that list — the SDK supports it — use `from auth import get_client` instead. Using raw HTTP for SDK-supported operations is the most common off-rails mistake. **Raw Web API (`get_token()`) is ONLY acceptable for:** forms, views, global option sets, N:N `$ref` associations, N:N `$expand`, `$apply` aggregation, memo columns, and unbound actions. Everything else MUST use MCP (if available) or the SDK. diff --git a/.github/plugins/dataverse/skills/dv-query/SKILL.md b/.github/plugins/dataverse/skills/dv-query/SKILL.md index 8752b0d..449798c 100644 --- a/.github/plugins/dataverse/skills/dv-query/SKILL.md +++ b/.github/plugins/dataverse/skills/dv-query/SKILL.md @@ -68,17 +68,15 @@ for r in results: ```python import os, sys sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_credential, load_env -from PowerPlatform.Dataverse.client import DataverseClient +from auth import get_client -load_env() -client = DataverseClient( - base_url=os.environ["DATAVERSE_URL"], - credential=get_credential(), -) +# get_client sets a plugin attribution context on the User-Agent header. +# Do not modify the context value — it is a closed schema for server-side +# telemetry (app/skill/agent). Never include secrets or PII. +client = get_client("dv-query") ``` -For scripts that run to completion: wrap in `with DataverseClient(...) as client:` for automatic connection cleanup (recommended since b6). For notebooks and interactive sessions, the explicit client above is simpler. +`get_client(skill)` handles auth, environment URL, and plugin attribution (User-Agent tagging). See `scripts/auth.py`. For scripts that run to completion, wrap the returned client in a `with` statement for automatic connection cleanup. --- diff --git a/.github/plugins/dataverse/skills/dv-query/references/web-api-advanced.md b/.github/plugins/dataverse/skills/dv-query/references/web-api-advanced.md index ee223f6..f305fa5 100644 --- a/.github/plugins/dataverse/skills/dv-query/references/web-api-advanced.md +++ b/.github/plugins/dataverse/skills/dv-query/references/web-api-advanced.md @@ -6,7 +6,7 @@ ```python import os, sys, json, urllib.request sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_token, load_env # get_token() is correct here — SDK cannot do this +from auth import get_token, get_plugin_headers, load_env # get_token + get_plugin_headers — SDK cannot do this load_env() env = os.environ["DATAVERSE_URL"].rstrip("/") @@ -16,10 +16,9 @@ token = get_token() url = (f"{env}/api/data/v9.2/new_tickets" f"?$select=new_name" f"&$expand=new_ticket_kbarticle($select=new_title)") -req = urllib.request.Request(url, headers={ - "Authorization": f"Bearer {token}", - "OData-MaxVersion": "4.0", "OData-Version": "4.0", "Accept": "application/json", -}) +headers = get_plugin_headers("dv-query", token) +headers.update({"OData-MaxVersion": "4.0", "OData-Version": "4.0", "Accept": "application/json"}) +req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req, timeout=150) as resp: data = json.loads(resp.read()) for ticket in data["value"]: @@ -45,19 +44,18 @@ with urllib.request.urlopen(req, timeout=150) as resp: ```python import os, sys, json, urllib.request sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_token, load_env # get_token() is correct here — SDK does not support $apply +from auth import get_token, get_plugin_headers, load_env # get_token + get_plugin_headers — SDK does not support $apply load_env() env = os.environ["DATAVERSE_URL"].rstrip("/") token = get_token() +_base_headers = get_plugin_headers("dv-query", token) +_base_headers.update({"OData-MaxVersion": "4.0", "OData-Version": "4.0", "Accept": "application/json"}) def apply_query(entity_set, apply_expr): """Run a $apply aggregation query. Returns list of result dicts.""" url = f"{env}/api/data/v9.2/{entity_set}?$apply={apply_expr}" - req = urllib.request.Request(url, headers={ - "Authorization": f"Bearer {token}", - "OData-MaxVersion": "4.0", "OData-Version": "4.0", "Accept": "application/json", - }) + req = urllib.request.Request(url, headers=_base_headers.copy()) with urllib.request.urlopen(req, timeout=150) as resp: return json.loads(resp.read()).get("value", []) diff --git a/.github/plugins/dataverse/skills/dv-solution/SKILL.md b/.github/plugins/dataverse/skills/dv-solution/SKILL.md index b423b6b..ef2d4bc 100644 --- a/.github/plugins/dataverse/skills/dv-solution/SKILL.md +++ b/.github/plugins/dataverse/skills/dv-solution/SKILL.md @@ -33,11 +33,12 @@ Every solution belongs to a publisher. The publisher's `customizationprefix` (e. ```python import os, sys sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_credential, load_env -from PowerPlatform.Dataverse.client import DataverseClient +from auth import get_client -load_env() -client = DataverseClient(os.environ["DATAVERSE_URL"], get_credential()) +# get_client sets a plugin attribution context on the User-Agent header. +# Do not modify the context value — it is a closed schema for server-side +# telemetry (app/skill/agent). Never include secrets or PII. +client = get_client("dv-solution") # 1. Query for existing non-Microsoft publishers pages = client.records.get( @@ -79,11 +80,12 @@ Use the SDK to create the solution record (preferred over raw Web API): ```python import os, sys sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_credential, load_env -from PowerPlatform.Dataverse.client import DataverseClient +from auth import get_client -load_env() -client = DataverseClient(os.environ["DATAVERSE_URL"], get_credential()) +# get_client sets a plugin attribution context on the User-Agent header. +# Do not modify the context value — it is a closed schema for server-side +# telemetry (app/skill/agent). Never include secrets or PII. +client = get_client("dv-solution") # Create the solution record solution_id = client.records.create("solution", { @@ -264,16 +266,15 @@ N:N `$expand` (like `systemuserroles_association`) is not supported by the SDK. # Web API required — SDK does not support N:N $expand import os, sys, urllib.request, json sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_token, load_env # get_token() is correct here — SDK can't do this +from auth import get_token, get_plugin_headers, load_env # get_token + get_plugin_headers — SDK can't do this load_env() env = os.environ["DATAVERSE_URL"].rstrip("/") token = get_token() url = f"{env}/api/data/v9.2/systemusers?$filter=internalemailaddress eq ''&$select=fullname&$expand=systemuserroles_association($select=name)&$top=1" -req = urllib.request.Request(url, headers={ - "Authorization": f"Bearer {token}", - "OData-MaxVersion": "4.0", "OData-Version": "4.0", "Accept": "application/json", -}) +headers = get_plugin_headers("dv-solution", token) +headers.update({"OData-MaxVersion": "4.0", "OData-Version": "4.0", "Accept": "application/json"}) +req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req) as resp: users = json.loads(resp.read()).get("value", []) if users: diff --git a/.github/plugins/dataverse/templates/CLAUDE.md b/.github/plugins/dataverse/templates/CLAUDE.md index 18dcaa8..7676371 100644 --- a/.github/plugins/dataverse/templates/CLAUDE.md +++ b/.github/plugins/dataverse/templates/CLAUDE.md @@ -33,12 +33,9 @@ rm ./solutions/{{SOLUTION_NAME}}.zip **Validate after push (using Python SDK):** ```python -from PowerPlatform.Dataverse.client import DataverseClient -from scripts.auth import get_credential, load_env -import os +from auth import get_client -load_env() -client = DataverseClient(base_url=os.environ["DATAVERSE_URL"], credential=get_credential()) +client = get_client("dv-data") # Check table exists info = client.tables.get("") diff --git a/CLAUDE.md b/CLAUDE.md index 35439b5..e1a0710 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,17 +33,19 @@ All code examples in skill files must be Python. No JavaScript, TypeScript, Powe ### Auth pattern -Every standalone Python block that imports from `auth` must use this exact pattern: +Every standalone Python block that imports from `auth` must use one of these patterns: ```python import os, sys sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) -from auth import get_credential, load_env # SDK operations +from auth import get_client # PREFERRED — SDK with plugin attribution +# OR +from auth import get_credential, load_env # SDK without attribution (context manager, notebooks) # OR from auth import get_token, load_env # Raw Web API only ``` -`get_credential()` is for SDK (`DataverseClient`) operations. `get_token()` is only for raw Web API calls (forms, views, `$apply`, N:N `$expand`) that the SDK does not support. Never use `get_token()` in a block containing `DataverseClient(`. +`get_client(skill)` is the preferred entry point — it handles auth, environment URL, and plugin attribution (User-Agent tagging) in one call. `get_credential()` is for advanced cases that need the raw credential (e.g., context manager pattern). `get_token()` is only for raw Web API calls (forms, views, `$apply`, N:N `$expand`) that the SDK does not support. Never use `get_token()` in a block containing `DataverseClient(`. The one exception: Jupyter notebook blocks use `InteractiveBrowserCredential` directly (no `scripts/` directory in a notebook environment). Mark this exception explicitly in prose above the block.