From dd693e8bd977bdb6e413033bc638bb226d5516e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:44:12 -0300 Subject: [PATCH 1/4] chore(deps): bump azure-keyvault-secrets from 4.9.0 to 4.11.0 in /config (#502) Bumps [azure-keyvault-secrets](https://github.com/Azure/azure-sdk-for-python) from 4.9.0 to 4.11.0. - [Release notes](https://github.com/Azure/azure-sdk-for-python/releases) - [Commits](https://github.com/Azure/azure-sdk-for-python/compare/azure-keyvault-secrets_4.9.0...azure-keyvault-secrets_4.11.0) --- updated-dependencies: - dependency-name: azure-keyvault-secrets dependency-version: 4.11.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- config/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/requirements.txt b/config/requirements.txt index fe56a3b2..8c34e199 100644 --- a/config/requirements.txt +++ b/config/requirements.txt @@ -14,5 +14,5 @@ azure-mgmt-cognitiveservices==13.6.0 # search requests==2.34.2 -azure-keyvault-secrets==4.9.0 +azure-keyvault-secrets==4.11.0 jinja2==3.1.6 \ No newline at end of file From d1feff83f2e89c4d92d9932a2c1662be42e1fadf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:44:52 -0300 Subject: [PATCH 2/4] chore(deps): bump azure-appconfiguration from 1.8.1 to 1.9.0 in /config (#540) Bumps [azure-appconfiguration](https://github.com/Azure/azure-sdk-for-python) from 1.8.1 to 1.9.0. - [Release notes](https://github.com/Azure/azure-sdk-for-python/releases) - [Commits](https://github.com/Azure/azure-sdk-for-python/compare/azure-appconfiguration_1.8.1...azure-appconfiguration_1.9.0) --- updated-dependencies: - dependency-name: azure-appconfiguration dependency-version: 1.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- config/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/requirements.txt b/config/requirements.txt index 8c34e199..b50c7acd 100644 --- a/config/requirements.txt +++ b/config/requirements.txt @@ -1,7 +1,7 @@ # general azure-identity==1.25.3 -azure-appconfiguration==1.8.1 +azure-appconfiguration==1.9.0 # app container apps From bfca4a4d8bf606fa55c8714f0d9947ccf00983b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:47:35 -0300 Subject: [PATCH 3/4] chore(deps): bump azure-mgmt-appcontainers in /config (#566) Bumps [azure-mgmt-appcontainers](https://github.com/Azure/azure-sdk-for-python) from 3.2.0 to 5.0.0. - [Release notes](https://github.com/Azure/azure-sdk-for-python/releases) - [Commits](https://github.com/Azure/azure-sdk-for-python/compare/azure-mgmt-appcontainers_3.2.0...azure-mgmt-appcontainers_5.0.0) --- updated-dependencies: - dependency-name: azure-mgmt-appcontainers dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- config/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/requirements.txt b/config/requirements.txt index b50c7acd..f8e865ff 100644 --- a/config/requirements.txt +++ b/config/requirements.txt @@ -5,7 +5,7 @@ azure-appconfiguration==1.9.0 # app container apps -azure-mgmt-appcontainers==3.2.0 +azure-mgmt-appcontainers==5.0.0 # aifoundry From e841185fa24ed473db62c2f28f76207fd96422fd Mon Sep 17 00:00:00 2001 From: Paulo Lacerda Date: Sun, 19 Jul 2026 12:09:26 -0300 Subject: [PATCH 4/4] feat(search): add generic MCP Server knowledge source template (preview) (#568) Add opt-in, disabled-by-default generic Foundry IQ MCP Server knowledge source provisioning with fail-closed canonical validation, safe request-time credential metadata, and comprehensive template and preflight coverage. Pin gpt-rag-orchestrator v3.7.0 as the minimum compatible runtime and lock the shared canonical source fixture to that released contract. Refs #567. The issue remains open until the umbrella release is published. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 8 + config/search/foundry_iq_mcp_setup.py | 734 +++++ config/search/search.j2 | 42 +- config/search/search.settings.j2 | 5 + config/search/setup.py | 40 + ...foundry_iq_mcp_canonical_source_3_7_0.json | 42 + .../search/tests/test_foundry_iq_templates.py | 2767 ++++++++++++----- manifest.json | 2 +- scripts/postProvision.ps1 | 79 +- 9 files changed, 2974 insertions(+), 745 deletions(-) create mode 100644 config/search/foundry_iq_mcp_setup.py create mode 100644 config/search/tests/fixtures/foundry_iq_mcp_canonical_source_3_7_0.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ff30dd9..020e0d14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,16 @@ ### Added +- **Generic MCP Server knowledge source template (preview, [Azure/GPT-RAG#567](https://github.com/Azure/GPT-RAG/issues/567), PR #568).** `config/search/search.j2` renders the `mcpServer` Foundry IQ knowledge source (Search API `2026-05-01-preview`) when `FOUNDRY_IQ_MCP_ENABLED=true` and at least one source is configured in `FOUNDRY_IQ_MCP_SOURCES_JSON`. Off by default; disabled rendering is byte-for-byte unchanged (`models: []`, `retrievalReasoningEffort: minimal`). When enabled, the knowledge base's `models` array reuses the same Azure OpenAI model object already rendered for standard-mode Blob content extraction as the planning model, and `retrievalReasoningEffort` is set to `low` or `medium` via `FOUNDRY_IQ_MCP_REASONING_EFFORT`. Each tool uses the official `outputParsing` object shape (`{"kind": "auto"|"none"|"split"}` or `{"kind": "json", "jsonParameters": {"documentsPath": ...}}`) and `inclusionMode` accepts `reranked` or `always`. Coexists with all existing knowledge sources (Blob, Search index, Work IQ, Fabric ontology/Data Agent, SharePoint remote/indexed, OneLake, Web). +- **`config/search/foundry_iq_mcp_setup.py`: fail-closed validation for MCP sources.** Unlike the soft/best-effort preflight pattern used by some other opt-in sources, `config/search/setup.py` now aborts provisioning with an actionable error when `FOUNDRY_IQ_MCP_ENABLED=true` and the configuration is invalid: malformed JSON, zero sources or tools, duplicate names, non-`https` `serverURL`, userinfo/query string/fragment/IP-literal/local/reserved hosts, a host outside `FOUNDRY_IQ_MCP_TRUSTED_HOSTS`, unsupported `outputParsing`/`inclusionMode` values, invalid output-parsing parameters, non-positive or over-cap (`8192`) `maxOutputTokens`, an `alwaysQuerySource` field, or any `auth`/`authentication` key or literal credential-shaped field anywhere in the JSON. Optional `queryHeaders` accepts only non-secret runtime metadata: managed identity or OBO with an explicit scope, Key Vault with a secret name, or `none` with no credential fields. Header names must be valid HTTP tokens and cannot be `Host`, `Content-Length`, or hop-by-hop headers. The canonical metadata is persisted for orchestrator request-time resolution but is never rendered into Search knowledge-source registration or knowledge-base parameters. The PowerShell preflight validates and canonicalizes the source JSON before any App Configuration write. When disabled, it skips source parsing and writes canonical `[]` plus safe MCP defaults. +- **Global knowledge source name uniqueness.** `config/search/setup.py` now validates, case-insensitively and before any Search API provisioning call, that every enabled/renderable Foundry IQ knowledge source name (Blob/Search Index, conversation upload, Work IQ, Fabric IQ, Fabric Data Agent, SharePoint Indexed, Web grounding, and every MCP Server source) is globally unique, aborting with an actionable error on the first collision. +- **`FOUNDRY_IQ_MCP_*` App Configuration passthrough.** `postProvision.ps1` seeds `FOUNDRY_IQ_MCP_ENABLED`, canonical `FOUNDRY_IQ_MCP_SOURCES_JSON`, `FOUNDRY_IQ_MCP_REASONING_EFFORT`, `FOUNDRY_IQ_MCP_TRUSTED_HOSTS`, and `FOUNDRY_IQ_MCP_LOG_TOOL_ARGUMENTS` with safe defaults so a fresh provision registers the keys without any config action from operators who do not opt in. Only validated non-secret `queryHeaders` references are retained in source JSON. +- **Docs: [Foundry IQ: Generic MCP server](https://azure.github.io/GPT-RAG/howto_grounding_mcp_server/) how-to (preview).** Covers the trusted-host and tool allowlist model, request-time managed identity/OBO/Key Vault header resolution, API Management / gateway and rate-limiting guidance, the Azure Monitor MCP reference scenario (workspace-scoped Log Analytics Data Reader, bounded time range and row caps, auditable KQL), rollout/canary/rollback via the existing disable gate, and production blockers. + ### Changed +- **`manifest.json`: orchestrator pin bumped from `v3.5.0` to [`v3.7.0`](https://github.com/Azure/gpt-rag-orchestrator/releases/tag/v3.7.0) for [#567](https://github.com/Azure/GPT-RAG/issues/567) / PR #568.** Orchestrator `v3.7.0` is the minimum compatible runtime for this preview because it consumes the canonical MCP source schema at request time, including `serverURL`, `outputParsing.jsonParameters`, `outputParsing.splitParameters`, `reranked`/`always` inclusion modes, and non-secret managed identity, OBO, Key Vault, or `none` query-header metadata. Earlier pins do not provide the complete runtime contract. Existing deployments must redeploy the `v3.7.0` orchestrator before enabling `FOUNDRY_IQ_MCP_ENABLED`. + ### Fixed ## [v3.5.0] - 2026-07-13 diff --git a/config/search/foundry_iq_mcp_setup.py b/config/search/foundry_iq_mcp_setup.py new file mode 100644 index 00000000..a9a37fe7 --- /dev/null +++ b/config/search/foundry_iq_mcp_setup.py @@ -0,0 +1,734 @@ +"""Foundry IQ generic MCP Server knowledge source validation (preview). + +MCP Server knowledge sources (kind ``mcpServer``, Search API +``2026-05-01-preview``) let a Foundry IQ knowledge base call tools exposed by +an arbitrary remote MCP server (for example, Azure Monitor MCP over +workspace-based Application Insights). Unlike the other knowledge sources in +this directory, MCP servers are attacker-reachable, operator-supplied remote +endpoints, so this module fails deployment closed (raises ``ValueError``) +instead of silently dropping a misconfigured source the way +``sharepoint_indexed_setup.filter_sharepoint_indexed_sources`` does. + +This module intentionally does NOT PUT the knowledge source itself; rendering +happens through the standard ``search.j2`` template (see the +``foundry_iq_mcp_enabled`` block) and registration goes through the existing +``provision_knowledge_sources`` path in ``config/search/setup.py``. This +module owns: + +1. ``is_foundry_iq_mcp_enabled`` -- the same enable gate used by the template + and by ``setup.py``. +2. ``validate_foundry_iq_mcp_settings`` -- the single entry point called from + ``setup.py`` before rendering. It parses and validates + ``FOUNDRY_IQ_MCP_SOURCES_JSON``, the trusted-host allowlist, the reasoning + effort, and the planning-model prerequisites, raising ``ValueError`` with + an actionable message on the first problem found. +3. A standalone CLI entry point (``python -m config.search.foundry_iq_mcp_setup``) + that validates ``FOUNDRY_IQ_MCP_SOURCES_JSON`` straight from process + environment variables. ``scripts/postProvision.ps1`` runs this as a + pre-flight gate, before it imports any settings into Azure App + Configuration, so an invalid/malicious MCP source is rejected before it is + ever persisted -- not just before the knowledge source is registered. + +``queryHeaders`` is runtime-only credential-resolution metadata. This module +accepts only strict, non-secret references (managed identity/OBO scopes, a Key +Vault secret name, or ``none``), persists that canonical metadata for the +orchestrator, and never renders it into the Search knowledge-source +registration payload. Literal credentials and every ``auth`` or +``authentication`` shape are rejected before any App Configuration write. +""" + +from __future__ import annotations + +import ipaddress +import json +import os +import re +import sys +from typing import Any +from urllib.parse import urlsplit + +MCP_KIND = "mcpServer" +MCP_PARAMS_KEY = "mcpServerParameters" + +LOCAL_MAX_OUTPUT_TOKENS_CAP = 8192 + +ALLOWED_INCLUSION_MODES = {"reranked", "always"} +ALLOWED_OUTPUT_PARSING_MODES = {"auto", "json", "split", "none"} +ALLOWED_REASONING_EFFORTS = {"low", "medium"} +DISALLOWED_HOSTNAMES = {"localhost", "localhost.localdomain", "home.arpa"} +DISALLOWED_HOST_SUFFIXES = ( + ".localhost", + ".local", + ".localdomain", + ".internal", + ".home", + ".home.arpa", + ".lan", + ".corp", + ".intranet", + ".private", + ".invalid", + ".test", + ".example", +) + +ALLOWED_SOURCE_KEYS = { + "name", + "description", + "serverURL", + "failOnError", + "maxOutputDocuments", + "tools", + "queryHeaders", +} +ALLOWED_TOOL_KEYS = {"name", "outputParsing", "inclusionMode", "maxOutputTokens"} +ALLOWED_OUTPUT_PARSING_KEYS = {"kind", "jsonParameters", "splitParameters"} +ALLOWED_JSON_PARAMETER_KEYS = {"documentsPath", "includeContext"} +ALLOWED_SPLIT_PARAMETER_KEYS = { + "textSplitMode", + "maximumPageLength", + "pageOverlapLength", + "maximumPagesToTake", + "defaultLanguageCode", +} +ALLOWED_TEXT_SPLIT_MODES = {"pages", "sentences"} +ALLOWED_QUERY_HEADER_KEYS = {"name", "valueFrom"} +ALLOWED_VALUE_FROM_KEYS = {"kind", "scope", "secretName"} +ALLOWED_VALUE_FROM_KINDS = {"managedIdentity", "obo", "keyVaultSecret", "none"} + +HEADER_NAME_PATTERN = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") +KNOWLEDGE_SOURCE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +KEY_VAULT_SECRET_NAME_PATTERN = re.compile(r"^[0-9A-Za-z-]{1,127}$") +DENIED_HEADER_NAMES = { + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +} + +# Keys that must never appear anywhere in FOUNDRY_IQ_MCP_SOURCES_JSON, no +# matter how deeply nested, checked before any other validation runs. +# Key comparisons ignore punctuation and casing so variants such as +# ``api_key`` and ``connection-string`` cannot bypass the scan. Metadata keys +# explicitly allowed by the schema (``queryHeaders``, ``valueFrom``, +# ``secretName``, and ``scope``) do not collide with these exact normalized +# names. +SECRET_LIKE_KEYS_ANY_DEPTH = { + "auth", + "authentication", + "apikey", + "accesstoken", + "authorizationvalue", + "bearer", + "clientsecret", + "connectionstring", + "connectionstrings", + "cookie", + "cookies", + "credential", + "credentials", + "header", + "headerblob", + "headers", + "secret", + "secretvalue", + "storedheaders", + "refreshtoken", + "token", + "password", + "key", + "idtoken", + "authorization", + "value", +} + + +def _is_truthy(value: Any) -> bool: + return str(value).strip().lower() in {"1", "true", "t", "yes", "y"} + + +def _parse_trusted_hosts(raw: Any) -> set[str]: + if not raw: + return set() + if isinstance(raw, str): + text = raw.strip() + if text.startswith("["): + try: + raw = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError("FOUNDRY_IQ_MCP_TRUSTED_HOSTS is not valid JSON.") from exc + else: + raw = re.split(r"[,\r\n]+", text) + if not isinstance(raw, (list, tuple, set)): + raise ValueError("FOUNDRY_IQ_MCP_TRUSTED_HOSTS must be a host list.") + + hosts: set[str] = set() + for item in raw: + host = str(item).strip().rstrip(".").lower() + if not host or "://" in host or "/" in host or ":" in host: + raise ValueError( + "FOUNDRY_IQ_MCP_TRUSTED_HOSTS entries must be hostnames only." + ) + hosts.add(host) + return hosts + + +def is_foundry_iq_mcp_enabled(context: dict) -> bool: + """Return True when generic MCP Server knowledge sources should be + registered on the Foundry IQ knowledge base. + + Requires the retrieval backend to be Foundry IQ and ``FOUNDRY_IQ_MCP_ENABLED`` + to be truthy. Whether ``FOUNDRY_IQ_MCP_SOURCES_JSON`` is well-formed and + non-empty is a validation concern, not a gating concern: an operator who + sets ``FOUNDRY_IQ_MCP_ENABLED=true`` with no (or invalid) sources must get + a hard failure from ``validate_and_get_mcp_sources``, not a silent no-op. + """ + + if str(context.get("RETRIEVAL_BACKEND") or "").lower() != "foundry_iq": + return False + return _is_truthy(context.get("FOUNDRY_IQ_MCP_ENABLED")) + + +def _validate_server_url(label: str, server_url: str, trusted_hosts: set[str]) -> None: + try: + parsed = urlsplit(server_url) + # Accessing ``port`` makes urllib validate non-numeric and out-of-range + # ports instead of leaving them in an otherwise parseable netloc. + _ = parsed.port + except ValueError as exc: + raise ValueError(f"{label}: 'serverURL' is not a valid URL.") from exc + + if parsed.scheme.lower() != "https": + raise ValueError(f"{label}: 'serverURL' must use https, got scheme '{parsed.scheme or '(none)'}'.") + if parsed.username or parsed.password: + raise ValueError(f"{label}: 'serverURL' must not contain userinfo (a username or password).") + if parsed.query or "?" in server_url: + raise ValueError(f"{label}: 'serverURL' must not contain a query string.") + if parsed.fragment: + raise ValueError(f"{label}: 'serverURL' must not contain a fragment.") + + hostname = parsed.hostname + if not hostname: + raise ValueError(f"{label}: 'serverURL' is missing a host.") + hostname_l = hostname.rstrip(".").lower() + + if ( + hostname_l in DISALLOWED_HOSTNAMES + or hostname_l.endswith(DISALLOWED_HOST_SUFFIXES) + or "." not in hostname_l + ): + raise ValueError(f"{label}: 'serverURL' must not use a local or reserved host.") + + try: + ipaddress.ip_address(hostname_l.strip("[]")) + is_ip_literal = True + except ValueError: + is_ip_literal = False + if is_ip_literal: + raise ValueError( + f"{label}: 'serverURL' host must be a DNS hostname, not an IP literal ('{hostname}'). " + "This blocks loopback, link-local, and other reserved IP ranges by construction." + ) + if hostname_l.rsplit(".", 1)[-1].isdigit(): + raise ValueError(f"{label}: 'serverURL' must not use a local or reserved host.") + + if not trusted_hosts: + raise ValueError( + f"{label}: FOUNDRY_IQ_MCP_TRUSTED_HOSTS is empty. Add the exact host '{hostname}' to the " + "allowlist before enabling this MCP source." + ) + if hostname_l not in trusted_hosts: + raise ValueError( + f"{label}: 'serverURL' host '{hostname}' is not an exact match in FOUNDRY_IQ_MCP_TRUSTED_HOSTS." + ) + + +def _normalized_key(key: Any) -> str: + return re.sub(r"[^a-z0-9]", "", str(key).strip().lower()) + + +def _scan_for_disallowed_keys_anywhere(node: Any, label: str) -> None: + """Recursively reject literal credential-shaped keys at any nesting depth. + + This runs before any other structural validation so a rejected key is + always reported clearly, regardless of where in the JSON it appears or + whether it also happens to be an otherwise-unexpected key. + """ + + if isinstance(node, dict): + for key, value in node.items(): + if _normalized_key(key) in SECRET_LIKE_KEYS_ANY_DEPTH: + raise ValueError( + f"{label}: key '{key}' is not allowed because literal credentials, auth objects, " + "stored headers, cookies, and connection strings must never appear in " + "FOUNDRY_IQ_MCP_SOURCES_JSON." + ) + _scan_for_disallowed_keys_anywhere(value, f"{label}.{key}") + elif isinstance(node, list): + for index, item in enumerate(node): + _scan_for_disallowed_keys_anywhere(item, f"{label}[{index}]") + + +def _validate_output_parsing(tool_label: str, value: Any) -> dict: + if not isinstance(value, dict): + raise ValueError(f"{tool_label}: 'outputParsing' must be a JSON object.") + + unexpected = set(value.keys()) - ALLOWED_OUTPUT_PARSING_KEYS + if unexpected: + raise ValueError(f"{tool_label}: 'outputParsing' has unexpected key(s) {sorted(unexpected)}.") + + kind = value.get("kind") + if not isinstance(kind, str) or kind not in ALLOWED_OUTPUT_PARSING_MODES: + raise ValueError( + f"{tool_label}: 'outputParsing.kind' must be one of " + f"{sorted(ALLOWED_OUTPUT_PARSING_MODES)}." + ) + + json_parameters = value.get("jsonParameters") + split_parameters = value.get("splitParameters") + if kind == "json": + if split_parameters is not None: + raise ValueError( + f"{tool_label}: 'outputParsing.splitParameters' is only valid when kind is 'split'." + ) + if not isinstance(json_parameters, dict): + raise ValueError( + f"{tool_label}: 'outputParsing.jsonParameters.documentsPath' is required " + "when kind is 'json'." + ) + unexpected = set(json_parameters.keys()) - ALLOWED_JSON_PARAMETER_KEYS + if unexpected: + raise ValueError( + f"{tool_label}: 'outputParsing.jsonParameters' has unexpected key(s) " + f"{sorted(unexpected)}." + ) + documents_path = json_parameters.get("documentsPath") + if not isinstance(documents_path, str) or not documents_path.strip(): + raise ValueError( + f"{tool_label}: 'outputParsing.jsonParameters.documentsPath' must be a non-empty string." + ) + canonical_json_parameters = {"documentsPath": documents_path.strip()} + include_context = json_parameters.get("includeContext") + if include_context is not None: + if not isinstance(include_context, bool): + raise ValueError( + f"{tool_label}: 'outputParsing.jsonParameters.includeContext' must be a boolean." + ) + canonical_json_parameters["includeContext"] = include_context + return { + "kind": "json", + "jsonParameters": canonical_json_parameters, + } + + if json_parameters is not None: + raise ValueError( + f"{tool_label}: 'outputParsing.jsonParameters' is only valid when kind is 'json'." + ) + if kind == "split": + if split_parameters is None: + return {"kind": "split"} + if not isinstance(split_parameters, dict): + raise ValueError(f"{tool_label}: 'outputParsing.splitParameters' must be a JSON object.") + unexpected = set(split_parameters.keys()) - ALLOWED_SPLIT_PARAMETER_KEYS + if unexpected: + raise ValueError( + f"{tool_label}: 'outputParsing.splitParameters' has unexpected key(s) " + f"{sorted(unexpected)}." + ) + + canonical_split_parameters: dict[str, Any] = {} + text_split_mode = split_parameters.get("textSplitMode") + if text_split_mode is not None: + if text_split_mode not in ALLOWED_TEXT_SPLIT_MODES: + raise ValueError( + f"{tool_label}: 'outputParsing.splitParameters.textSplitMode' must be one of " + f"{sorted(ALLOWED_TEXT_SPLIT_MODES)}." + ) + canonical_split_parameters["textSplitMode"] = text_split_mode + + for key in ("maximumPageLength", "maximumPagesToTake"): + parameter = split_parameters.get(key) + if parameter is not None: + if isinstance(parameter, bool) or not isinstance(parameter, int) or parameter <= 0: + raise ValueError( + f"{tool_label}: 'outputParsing.splitParameters.{key}' must be a positive integer." + ) + canonical_split_parameters[key] = parameter + + page_overlap_length = split_parameters.get("pageOverlapLength") + if page_overlap_length is not None: + if ( + isinstance(page_overlap_length, bool) + or not isinstance(page_overlap_length, int) + or page_overlap_length < 0 + ): + raise ValueError( + f"{tool_label}: 'outputParsing.splitParameters.pageOverlapLength' " + "must be a non-negative integer." + ) + maximum_page_length = split_parameters.get("maximumPageLength") + if maximum_page_length is not None and page_overlap_length >= maximum_page_length: + raise ValueError( + f"{tool_label}: 'outputParsing.splitParameters.pageOverlapLength' " + "must be less than maximumPageLength." + ) + canonical_split_parameters["pageOverlapLength"] = page_overlap_length + + default_language_code = split_parameters.get("defaultLanguageCode") + if default_language_code is not None: + if not isinstance(default_language_code, str) or not default_language_code.strip(): + raise ValueError( + f"{tool_label}: 'outputParsing.splitParameters.defaultLanguageCode' " + "must be a non-empty string." + ) + canonical_split_parameters["defaultLanguageCode"] = default_language_code.strip() + + return {"kind": "split", "splitParameters": canonical_split_parameters} + if split_parameters is not None: + raise ValueError( + f"{tool_label}: 'outputParsing.splitParameters' is only valid when kind is 'split'." + ) + return {"kind": kind} + + +def _validate_tool(source_label: str, tool: Any, seen_tool_names: set[str]) -> dict: + if not isinstance(tool, dict): + raise ValueError(f"{source_label}: each tool must be a JSON object.") + + unexpected = set(tool.keys()) - ALLOWED_TOOL_KEYS + if unexpected: + raise ValueError(f"{source_label}: tool has unexpected key(s) {sorted(unexpected)}.") + + name_value = tool.get("name") + if not isinstance(name_value, str) or not name_value.strip(): + raise ValueError(f"{source_label}: tool 'name' is required.") + name = name_value.strip() + normalized_name = name.casefold() + if normalized_name in seen_tool_names: + raise ValueError(f"{source_label}: tool name '{name}' is used more than once; tool names must be unique.") + seen_tool_names.add(normalized_name) + tool_label = f"{source_label} tool '{name}'" + + output_parsing = _validate_output_parsing(tool_label, tool.get("outputParsing")) + + inclusion_mode = tool.get("inclusionMode") + if inclusion_mode not in ALLOWED_INCLUSION_MODES: + raise ValueError( + f"{tool_label}: 'inclusionMode' must be one of {sorted(ALLOWED_INCLUSION_MODES)}." + ) + + max_output_tokens = tool.get("maxOutputTokens") + if isinstance(max_output_tokens, bool) or not isinstance(max_output_tokens, int) or max_output_tokens <= 0: + raise ValueError(f"{tool_label}: 'maxOutputTokens' must be a positive integer.") + if max_output_tokens > LOCAL_MAX_OUTPUT_TOKENS_CAP: + raise ValueError( + f"{tool_label}: 'maxOutputTokens' ({max_output_tokens}) exceeds the local cap of " + f"{LOCAL_MAX_OUTPUT_TOKENS_CAP}." + ) + return { + "name": name, + "outputParsing": output_parsing, + "inclusionMode": inclusion_mode, + "maxOutputTokens": max_output_tokens, + } + + +def _validate_query_header(source_label: str, header: Any, seen_names: set[str]) -> dict: + if not isinstance(header, dict): + raise ValueError(f"{source_label}: each queryHeaders entry must be a JSON object.") + + unexpected = set(header.keys()) - ALLOWED_QUERY_HEADER_KEYS + if unexpected: + raise ValueError(f"{source_label}: queryHeaders entry has unexpected key(s) {sorted(unexpected)}.") + + name_value = header.get("name") + if not isinstance(name_value, str): + raise ValueError(f"{source_label}: query header 'name' must be a string.") + name = name_value.strip() + if not HEADER_NAME_PATTERN.fullmatch(name): + raise ValueError(f"{source_label}: query header name is not a valid HTTP field name.") + if name.lower() in DENIED_HEADER_NAMES: + raise ValueError(f"{source_label}: query header '{name}' is not allowed.") + if name.casefold() in seen_names: + raise ValueError(f"{source_label}: query header names must be unique within a source.") + seen_names.add(name.casefold()) + + value_from = header.get("valueFrom") + if not isinstance(value_from, dict): + raise ValueError(f"{source_label}: query header 'valueFrom' must be a JSON object.") + unexpected = set(value_from.keys()) - ALLOWED_VALUE_FROM_KEYS + if unexpected: + raise ValueError( + f"{source_label}: query header 'valueFrom' has unexpected key(s) {sorted(unexpected)}." + ) + + kind = value_from.get("kind") + if kind not in ALLOWED_VALUE_FROM_KINDS: + raise ValueError( + f"{source_label}: query header 'valueFrom.kind' must be one of " + f"{sorted(ALLOWED_VALUE_FROM_KINDS)}." + ) + + scope = value_from.get("scope") + secret_name = value_from.get("secretName") + if kind in {"managedIdentity", "obo"}: + if not isinstance(scope, str) or not scope.strip(): + raise ValueError(f"{source_label}: query header kind '{kind}' requires an explicit scope.") + if any(ord(character) < 0x20 or ord(character) == 0x7F for character in scope): + raise ValueError(f"{source_label}: query header scope contains control characters.") + if secret_name is not None: + raise ValueError(f"{source_label}: 'secretName' is not valid for query header kind '{kind}'.") + canonical_value_from = {"kind": kind, "scope": scope.strip()} + elif kind == "keyVaultSecret": + if scope is not None: + raise ValueError(f"{source_label}: 'scope' is not valid for query header kind 'keyVaultSecret'.") + if not isinstance(secret_name, str) or not KEY_VAULT_SECRET_NAME_PATTERN.fullmatch(secret_name): + raise ValueError( + f"{source_label}: query header kind 'keyVaultSecret' requires a valid secretName." + ) + canonical_value_from = {"kind": kind, "secretName": secret_name} + else: + if scope is not None or secret_name is not None: + raise ValueError( + f"{source_label}: query header kind 'none' must not carry scope or secretName." + ) + canonical_value_from = {"kind": "none"} + + return {"name": name, "valueFrom": canonical_value_from} + + +def validate_and_get_mcp_sources(context: dict) -> list[dict]: + """Parse, validate, and return the configured MCP sources. + + Returns an empty list when the feature is not enabled. Raises + ``ValueError`` with an actionable message on the first invalid field + found when it is enabled. + """ + + if not is_foundry_iq_mcp_enabled(context): + return [] + + sources = context.get("FOUNDRY_IQ_MCP_SOURCES_JSON") + if not isinstance(sources, list): + raise ValueError( + "FOUNDRY_IQ_MCP_SOURCES_JSON must be a JSON array of MCP source objects; " + f"got {type(sources).__name__}." + ) + if not sources: + raise ValueError( + "FOUNDRY_IQ_MCP_ENABLED is true but FOUNDRY_IQ_MCP_SOURCES_JSON has no sources. " + "Add at least one MCP source or set FOUNDRY_IQ_MCP_ENABLED=false." + ) + + # Reject auth objects and literal credential-shaped keys anywhere in the + # raw JSON before any structural validation runs. + _scan_for_disallowed_keys_anywhere(sources, "FOUNDRY_IQ_MCP_SOURCES_JSON") + + trusted_hosts = _parse_trusted_hosts(context.get("FOUNDRY_IQ_MCP_TRUSTED_HOSTS")) + reasoning_effort = str(context.get("FOUNDRY_IQ_MCP_REASONING_EFFORT") or "low").strip().lower() + if reasoning_effort not in ALLOWED_REASONING_EFFORTS: + raise ValueError( + f"FOUNDRY_IQ_MCP_REASONING_EFFORT must be one of {sorted(ALLOWED_REASONING_EFFORTS)}, " + f"got '{reasoning_effort}'. MCP knowledge sources require the query planner to run." + ) + + canonical_sources: list[dict] = [] + seen_names: set[str] = set() + for index, source in enumerate(sources): + label = f"FOUNDRY_IQ_MCP_SOURCES_JSON[{index}]" + if not isinstance(source, dict): + raise ValueError(f"{label}: must be a JSON object.") + + unexpected = set(source.keys()) - ALLOWED_SOURCE_KEYS + if unexpected: + raise ValueError(f"{label}: unexpected key(s) {sorted(unexpected)}.") + # Note: 'alwaysQuerySource' is deliberately absent from + # ALLOWED_SOURCE_KEYS, so the unexpected-key check above already + # rejects it. MCP sources are never forced into every retrieval. + + name_value = source.get("name") + if not isinstance(name_value, str) or not name_value.strip(): + raise ValueError(f"{label}: 'name' is required.") + name = name_value.strip() + if not KNOWLEDGE_SOURCE_NAME_PATTERN.fullmatch(name): + raise ValueError( + f"{label}: 'name' must start with a letter or number, contain only letters, " + "numbers, '.', '_' or '-', and be at most 128 characters." + ) + normalized_name = name.casefold() + if normalized_name in seen_names: + raise ValueError(f"MCP source name '{name}' is used more than once; source names must be unique.") + seen_names.add(normalized_name) + label = f"MCP source '{name}'" + + description_value = source.get("description") + if description_value is not None and not isinstance(description_value, str): + raise ValueError(f"{label}: 'description' must be a string when provided.") + + server_url_value = source.get("serverURL") + if not isinstance(server_url_value, str) or not server_url_value.strip(): + raise ValueError(f"{label}: 'serverURL' is required.") + server_url = server_url_value.strip() + _validate_server_url(label, server_url, trusted_hosts) + + fail_on_error = source.get("failOnError") + if "failOnError" in source and not isinstance(fail_on_error, bool): + raise ValueError(f"{label}: 'failOnError' must be a boolean when provided.") + + max_output_documents = source.get("maxOutputDocuments") + if max_output_documents is not None and ( + isinstance(max_output_documents, bool) + or not isinstance(max_output_documents, int) + or not 1 <= max_output_documents <= 50 + ): + raise ValueError( + f"{label}: 'maxOutputDocuments' must be an integer between 1 and 50 when provided." + ) + + tools = source.get("tools") + if not isinstance(tools, list) or not tools: + raise ValueError(f"{label}: 'tools' must be a non-empty array.") + seen_tool_names: set[str] = set() + canonical_tools: list[dict] = [] + for tool in tools: + canonical_tools.append(_validate_tool(label, tool, seen_tool_names)) + + query_headers = source.get("queryHeaders", []) + if not isinstance(query_headers, list): + raise ValueError(f"{label}: 'queryHeaders' must be an array when provided.") + seen_header_names: set[str] = set() + canonical_headers = [ + _validate_query_header(label, header, seen_header_names) + for header in query_headers + ] + + canonical_source = { + "name": name, + "serverURL": server_url, + "tools": canonical_tools, + } + if description_value is not None: + canonical_source["description"] = description_value.strip() + if "failOnError" in source: + canonical_source["failOnError"] = fail_on_error + if "maxOutputDocuments" in source: + canonical_source["maxOutputDocuments"] = max_output_documents + if "queryHeaders" in source: + canonical_source["queryHeaders"] = canonical_headers + canonical_sources.append(canonical_source) + + return canonical_sources + + +def validate_foundry_iq_mcp_settings(context: dict) -> None: + """Top-level fail-closed validation entry point, called from + ``setup.py`` right after ``validate_foundry_iq_settings``. + + Raises ``ValueError`` (aborting the whole provisioning run) when + ``FOUNDRY_IQ_MCP_ENABLED`` is true and the configuration is invalid, or + when no planning model is available for the knowledge base. When the + feature is disabled this is a no-op so disabled deployments render + exactly as before. + """ + + if not is_foundry_iq_mcp_enabled(context): + # Do not inspect or parse stale source content while disabled. Keeping + # the context canonical also prevents a later App Configuration write + # from re-persisting disabled source data. + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = [] + return + + # Validate and recursively scan all source content before checking later + # prerequisites or allowing setup.py to persist the rendered settings. + sources = validate_and_get_mcp_sources(context) + if not context.get("GPT_MODEL_INFO"): + raise ValueError( + "FOUNDRY_IQ_MCP_ENABLED is true but no chat model was found in MODEL_DEPLOYMENTS (canonical_name " + "'CHAT_DEPLOYMENT_NAME'). MCP knowledge sources require a planning model for tool selection and " + "argument generation." + ) + if not context.get("FOUNDRY_IQ_AI_SERVICES_ENDPOINT"): + raise ValueError( + "FOUNDRY_IQ_MCP_ENABLED is true but no AI Services endpoint could be derived. Set " + "FOUNDRY_IQ_AI_SERVICES_ENDPOINT, AI_FOUNDRY_PROJECT_ENDPOINT, or AI_FOUNDRY_ACCOUNT_NAME." + ) + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = sources + + +def _parse_env_sources_json(raw: str) -> Any: + """Parse the raw FOUNDRY_IQ_MCP_SOURCES_JSON environment variable value. + + Used only by the standalone CLI pre-flight (below); ``setup.py`` itself + reads already-parsed values via ``load_appconfig_settings``. + """ + + text = (raw or "").strip() + if not text: + return [] + try: + return json.loads(text) + except json.JSONDecodeError as je: + raise ValueError(f"FOUNDRY_IQ_MCP_SOURCES_JSON is not valid JSON: {je}") from je + + +def build_preflight_context_from_environ() -> dict: + """Build the minimal validation context ``validate_and_get_mcp_sources`` + needs, sourced directly from process environment variables. + + This lets this module be invoked standalone, as a provisioning + pre-flight gate, against the operator's raw candidate settings -- + before ``scripts/postProvision.ps1`` imports anything into Azure App + Configuration. Only the fields ``validate_and_get_mcp_sources`` reads + are included; the planning-model/AI-Services-endpoint checks in + ``validate_foundry_iq_mcp_settings`` need App-Configuration-derived data + (``MODEL_DEPLOYMENTS``) that does not exist yet at this point in + provisioning, so they are intentionally not part of this pre-flight gate + and remain covered later by ``config.search.setup`` itself. + """ + + context = { + "RETRIEVAL_BACKEND": os.environ.get("RETRIEVAL_BACKEND", "foundry_iq"), + "FOUNDRY_IQ_MCP_ENABLED": os.environ.get("FOUNDRY_IQ_MCP_ENABLED", "false"), + "FOUNDRY_IQ_MCP_SOURCES_JSON": [], + "FOUNDRY_IQ_MCP_TRUSTED_HOSTS": os.environ.get("FOUNDRY_IQ_MCP_TRUSTED_HOSTS", ""), + "FOUNDRY_IQ_MCP_REASONING_EFFORT": os.environ.get("FOUNDRY_IQ_MCP_REASONING_EFFORT", "low"), + } + # Check the explicit enablement gate before touching the raw JSON. This + # keeps disabled provisioning independent of stale or malformed source + # content left in the environment. + if is_foundry_iq_mcp_enabled(context): + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = _parse_env_sources_json( + os.environ.get("FOUNDRY_IQ_MCP_SOURCES_JSON", "[]") + ) + return context + + +def main(*, emit_canonical: bool = False) -> int: + """Pre-flight CLI entry point. + + Validates FOUNDRY_IQ_MCP_SOURCES_JSON (and the other FOUNDRY_IQ_MCP_* + environment variables) before import. With ``emit_canonical=True`` it + writes the canonical, metadata-only source JSON to stdout for the + PowerShell preflight to persist. Errors go to stderr without rejected + values. This module never writes App Configuration itself. + """ + + try: + context = build_preflight_context_from_environ() + sources = validate_and_get_mcp_sources(context) + except ValueError as ve: + print(f"❗️ FOUNDRY_IQ_MCP_SOURCES_JSON validation failed: {ve}", file=sys.stderr) + return 1 + if emit_canonical: + print(json.dumps(sources, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(emit_canonical="--canonical" in sys.argv[1:])) diff --git a/config/search/search.j2 b/config/search/search.j2 index 14ca82e9..d1920935 100644 --- a/config/search/search.j2 +++ b/config/search/search.j2 @@ -15,6 +15,9 @@ {% for _d in (WEB_GROUNDING_BLOCKED_DOMAINS | default('', true)).replace(';', ',').replace('\n', ',').split(',') %} {% if _d.strip() %}{% set _ = web_blocked_domains.append(_d.strip().lower()) %}{% endif %} {% endfor %} +{% set foundry_iq_mcp_sources = FOUNDRY_IQ_MCP_SOURCES_JSON if (FOUNDRY_IQ_MCP_SOURCES_JSON is iterable and FOUNDRY_IQ_MCP_SOURCES_JSON is not string and FOUNDRY_IQ_MCP_SOURCES_JSON is not mapping) else [] %} +{% set foundry_iq_mcp_enabled = RETRIEVAL_BACKEND == 'foundry_iq' and (FOUNDRY_IQ_MCP_ENABLED | default('false') | string | lower) in ['true', '1', 'yes', 'y', 't'] and (foundry_iq_mcp_sources | length) > 0 %} +{% set foundry_iq_mcp_reasoning_effort = FOUNDRY_IQ_MCP_REASONING_EFFORT | default('low', true) %} { "indexes": [ { @@ -483,6 +486,29 @@ "encryptionKey": null } {% endif %} + {% if foundry_iq_mcp_enabled %} + {% for mcp_src in foundry_iq_mcp_sources %}, + { + "name": {{ mcp_src.name | tojson }}, + "kind": "mcpServer", + "description": {{ (mcp_src.description | default('MCP server knowledge source (' ~ mcp_src.name ~ ').', true)) | tojson }}, + "mcpServerParameters": { + "serverURL": {{ mcp_src.serverURL | tojson }}, + "tools": [ + {% for tool in mcp_src.tools %}{% if not loop.first %},{% endif %} + { + "name": {{ tool.name | tojson }}, + "outputParsing": {{ tool.outputParsing | tojson }}, + "inclusionMode": {{ tool.inclusionMode | tojson }}, + "maxOutputTokens": {{ tool.maxOutputTokens }} + } + {% endfor %} + ] + }, + "encryptionKey": null + } + {% endfor %} + {% endif %} ]{% else %}[]{% endif %}, "knowledgeBases": {% if RETRIEVAL_BACKEND == 'foundry_iq' %}[ { @@ -498,11 +524,21 @@ { "name": "{{FABRIC_IQ_KNOWLEDGE_SOURCE_NAME}}" }{% endif %}{% if fabric_data_agent_enabled %}, { "name": "{{FABRIC_DATA_AGENT_KNOWLEDGE_SOURCE_NAME}}" }{% endif %}{% if sharepoint_indexed_enabled %}, { "name": "{{SHAREPOINT_INDEXED_KNOWLEDGE_SOURCE_NAME}}" }{% endif %}{% if web_grounding_enabled %}, - { "name": "{{WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME}}" }{% endif %} + { "name": "{{WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME}}" }{% endif %}{% if foundry_iq_mcp_enabled %}{% for mcp_src in foundry_iq_mcp_sources %}, + { "name": {{ mcp_src.name | tojson }} }{% endfor %}{% endif %} ], - "models": [], + "models": {% if foundry_iq_mcp_enabled %}[ + { + "kind": "azureOpenAI", + "azureOpenAIParameters": { + "resourceUri": "{{FOUNDRY_IQ_AI_SERVICES_ENDPOINT}}", + "deploymentId": "{{GPT_MODEL_INFO.deployment_name}}", + "modelName": "{{GPT_MODEL_INFO.model_name}}" + } + } + ]{% else %}[]{% endif %}, "encryptionKey": null, - "retrievalReasoningEffort": { "kind": "minimal" } + "retrievalReasoningEffort": { "kind": "{% if foundry_iq_mcp_enabled %}{{foundry_iq_mcp_reasoning_effort}}{% else %}minimal{% endif %}" } } ]{% else %}[]{% endif %} } diff --git a/config/search/search.settings.j2 b/config/search/search.settings.j2 index 0fe6dee0..9f6054cb 100644 --- a/config/search/search.settings.j2 +++ b/config/search/search.settings.j2 @@ -55,6 +55,11 @@ "WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME": "{{WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME | default('')}}", "WEB_GROUNDING_ALLOWED_DOMAINS": "{{WEB_GROUNDING_ALLOWED_DOMAINS | default('')}}", "WEB_GROUNDING_BLOCKED_DOMAINS": "{{WEB_GROUNDING_BLOCKED_DOMAINS | default('')}}", + "FOUNDRY_IQ_MCP_ENABLED": "{{FOUNDRY_IQ_MCP_ENABLED | default('false')}}", + "FOUNDRY_IQ_MCP_SOURCES_JSON": {{FOUNDRY_IQ_MCP_SOURCES_JSON | default([]) | tojson}}, + "FOUNDRY_IQ_MCP_REASONING_EFFORT": "{{FOUNDRY_IQ_MCP_REASONING_EFFORT | default('low')}}", + "FOUNDRY_IQ_MCP_TRUSTED_HOSTS": "{{FOUNDRY_IQ_MCP_TRUSTED_HOSTS | default('')}}", + "FOUNDRY_IQ_MCP_LOG_TOOL_ARGUMENTS": "{{FOUNDRY_IQ_MCP_LOG_TOOL_ARGUMENTS | default('false')}}", {% if GPT_MODEL_INFO | default({}) %} "GPT_MODEL_DEPLOYMENT_NAME": "{{GPT_MODEL_INFO.deployment_name}}", "GPT_MODEL_NAME": "{{GPT_MODEL_INFO.model_name}}" diff --git a/config/search/setup.py b/config/search/setup.py index 26c49975..f91066b0 100644 --- a/config/search/setup.py +++ b/config/search/setup.py @@ -38,6 +38,8 @@ from azure.appconfiguration import AzureAppConfigurationClient, ConfigurationSetting from jinja2 import Environment, FileSystemLoader, StrictUndefined, TemplateError +from config.search.foundry_iq_mcp_setup import validate_foundry_iq_mcp_settings + # ── Silence verbose logging ───────────────────────────────────────────────── for logger_name in ( "azure.core.pipeline.policies.http_logging_policy", @@ -320,9 +322,15 @@ def render_and_parse_json(template_name_inner: str, ctx: dict) -> Optional[dict] vars_dict["FOUNDRY_IQ_AI_SERVICES_ENDPOINT"] = ai_services_endpoint try: validate_foundry_iq_settings(context) + validate_foundry_iq_mcp_settings(context) except ValueError as ve: logging.error(str(ve)) return None, context + # The MCP validator returns a canonical disabled value or a + # deep-copied, normalized source model. Persist that model rather + # than the raw App Configuration value. + if "FOUNDRY_IQ_MCP_SOURCES_JSON" in context: + vars_dict["FOUNDRY_IQ_MCP_SOURCES_JSON"] = context["FOUNDRY_IQ_MCP_SOURCES_JSON"] for key, val in vars_dict.items(): if isinstance(val, (dict, list)): final_val = json.dumps(val) @@ -599,6 +607,37 @@ def filter_work_iq_sources(defs: dict, context: dict, cred: ChainedTokenCredenti ] +def validate_unique_knowledge_source_names(defs: dict) -> None: + """Fail closed when the rendered template would register two or more + knowledge sources under the same name, case-insensitively. + + Azure AI Search knowledge source names are unique per search service; a + case-only collision between, say, the Blob source and an MCP source + would silently overwrite one of them at registration time. This checks + ``defs["knowledgeSources"]`` -- the already-rendered list, which by + construction only contains sources that search.j2's own per-kind + enablement gates decided are enabled/renderable (Blob or Search Index, + the conversation-upload Search Index, Work IQ, Fabric IQ, Fabric Data + Agent, SharePoint Indexed, Web grounding, and every MCP Server source) -- + rather than re-deriving each kind's enablement logic here. + """ + + seen: dict[str, str] = {} + for ks in defs.get("knowledgeSources") or []: + name = str(ks.get("name") or "").strip() + if not name: + continue + key = name.lower() + if key in seen: + raise ValueError( + f"Knowledge source name '{name}' collides (case-insensitive) with '{seen[key]}'. " + "Knowledge source names must be globally unique across every enabled source " + "(Blob/Search Index, Work IQ, Fabric IQ, Fabric Data Agent, SharePoint Indexed, Web " + "grounding, MCP Server); rename one of them before provisioning." + ) + seen[key] = name + + def provision_knowledge_sources(defs: dict, context: dict, cred: ChainedTokenCredential, search_endpoint: str): """Create or update Foundry IQ knowledge sources. @@ -698,6 +737,7 @@ def provision_knowledge_bases(defs: dict, context: dict, cred: ChainedTokenCrede def execute_setup(defs: Optional[dict], context: dict): if defs is None: raise RuntimeError("No search definitions were rendered; aborting Azure Search setup") + validate_unique_knowledge_source_names(defs) cred = ChainedTokenCredential(AzureCliCredential(),ManagedIdentityCredential()) indexers = defs.get("indexers", []) ds_to_indexers = {} diff --git a/config/search/tests/fixtures/foundry_iq_mcp_canonical_source_3_7_0.json b/config/search/tests/fixtures/foundry_iq_mcp_canonical_source_3_7_0.json new file mode 100644 index 00000000..c2dc7960 --- /dev/null +++ b/config/search/tests/fixtures/foundry_iq_mcp_canonical_source_3_7_0.json @@ -0,0 +1,42 @@ +{ + "name": "monitor-mcp", + "description": "Read-only Azure Monitor MCP source.", + "serverURL": "https://mcp.contoso.com/mcp", + "tools": [ + { + "name": "query_logs", + "outputParsing": { + "kind": "json", + "jsonParameters": { + "documentsPath": "$.results[*]", + "includeContext": true + } + }, + "inclusionMode": "reranked", + "maxOutputTokens": 2048 + }, + { + "name": "get_status", + "outputParsing": { + "kind": "auto" + }, + "inclusionMode": "always", + "maxOutputTokens": 512 + }, + { + "name": "split_report", + "outputParsing": { + "kind": "split", + "splitParameters": { + "textSplitMode": "pages", + "maximumPageLength": 4000, + "pageOverlapLength": 500, + "maximumPagesToTake": 10, + "defaultLanguageCode": "en" + } + }, + "inclusionMode": "reranked", + "maxOutputTokens": 1024 + } + ] +} diff --git a/config/search/tests/test_foundry_iq_templates.py b/config/search/tests/test_foundry_iq_templates.py index 7747c634..db06f082 100644 --- a/config/search/tests/test_foundry_iq_templates.py +++ b/config/search/tests/test_foundry_iq_templates.py @@ -1,731 +1,2036 @@ -import json -import unittest -from pathlib import Path -from unittest.mock import Mock, patch - -from jinja2 import Environment, FileSystemLoader, StrictUndefined - -from config.search import setup - - -TEMPLATE_DIR = Path(__file__).resolve().parents[1] - - -def render_json_template(template_name, context): - env = Environment( - loader=FileSystemLoader(TEMPLATE_DIR), - undefined=StrictUndefined, - keep_trailing_newline=True, - ) - rendered = env.get_template(template_name).render(**context) - return json.loads(rendered) - - -class FoundryIqTemplateTests(unittest.TestCase): - def test_settings_template_derives_search_index_before_dependent_defaults(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_PROJECT_ENDPOINT": "https://aif-abc123.services.ai.azure.com/api/projects/proj", - }, - ) - - self.assertEqual(settings["SEARCH_RAG_INDEX_NAME"], "ragindex-abc123") - self.assertEqual(settings["KNOWLEDGE_BASE_NAME"], "ragindex-abc123-rag-kb") - self.assertEqual(settings["FOUNDRY_IQ_KNOWLEDGE_SOURCE_NAME"], "ragindex-abc123-blob-ks") - self.assertEqual( - settings["FOUNDRY_IQ_AI_SERVICES_ENDPOINT"], - "https://aif-abc123.services.ai.azure.com/", - ) - - def test_standard_blob_knowledge_source_includes_ai_services_endpoint_without_unsupported_chat_model(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - "FOUNDRY_IQ_CONTENT_EXTRACTION_MODE": "standard", - }, - ) - context = { - **settings, - "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", - "EMBEDDING_MODEL_INFO": { - "endpoint": "https://aif-abc123.openai.azure.com/", - "deployment_name": "text-embedding", - "model_name": "text-embedding-3-large", - }, - "GPT_MODEL_INFO": { - "deployment_name": "chat", - "model_name": "gpt-5-nano", - }, - } - - search_definitions = render_json_template("search.j2", context) - knowledge_source = search_definitions["knowledgeSources"][0] - ingestion_parameters = knowledge_source["azureBlobParameters"]["ingestionParameters"] - - self.assertEqual(ingestion_parameters["contentExtractionMode"], "standard") - self.assertEqual( - ingestion_parameters["aiServices"]["uri"], - "https://aif-abc123.services.ai.azure.com/", - ) - self.assertIsNone(ingestion_parameters["chatCompletionModel"]) - - def test_standard_blob_knowledge_source_includes_supported_chat_model(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - "FOUNDRY_IQ_CONTENT_EXTRACTION_MODE": "standard", - }, - ) - context = { - **settings, - "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", - "EMBEDDING_MODEL_INFO": { - "endpoint": "https://aif-abc123.openai.azure.com/", - "deployment_name": "text-embedding", - "model_name": "text-embedding-3-large", - }, - "GPT_MODEL_INFO": { - "deployment_name": "chat", - "model_name": "gpt-5.2", - }, - } - - search_definitions = render_json_template("search.j2", context) - knowledge_source = search_definitions["knowledgeSources"][0] - ingestion_parameters = knowledge_source["azureBlobParameters"]["ingestionParameters"] - - self.assertEqual( - ingestion_parameters["chatCompletionModel"]["azureOpenAIParameters"]["resourceUri"], - ingestion_parameters["aiServices"]["uri"], - ) - self.assertEqual( - ingestion_parameters["chatCompletionModel"]["azureOpenAIParameters"]["modelName"], - "gpt-5.2", - ) - - def test_network_isolated_blob_knowledge_source_sets_generated_indexer_private(self): - credential = Mock() - credential.get_token.return_value.token = "token" - get_response = Mock() - get_response.status_code = 200 - get_response.json.return_value = { - "@odata.context": "https://search/$metadata#indexers/$entity", - "name": "blob-ks-indexer", - "dataSourceName": "blob-ks-datasource", - "targetIndexName": "blob-ks-index", - "parameters": { - "configuration": { - "dataToExtract": "contentAndMetadata", - } - }, - } - put_response = Mock() - put_response.status_code = 200 - - with patch.object(setup.requests, "get", return_value=get_response), patch.object( - setup.requests, "put", return_value=put_response - ) as put: - setup.enforce_private_execution_for_generated_indexers( - { - "knowledgeSources": [ - { - "name": "blob-ks", - "kind": "azureBlob", - "azureBlobParameters": {}, - } - ] - }, - {"NETWORK_ISOLATION": "true"}, - credential, - "https://search.search.windows.net", - "2025-05-01-preview", - ) - - body = put.call_args.kwargs["json"] - self.assertNotIn("@odata.context", body) - self.assertEqual(body["parameters"]["configuration"]["executionEnvironment"], "Private") - - def test_non_network_isolated_setup_does_not_update_generated_indexer(self): - credential = Mock() - - with patch.object(setup.requests, "get") as get: - setup.enforce_private_execution_for_generated_indexers( - { - "knowledgeSources": [ - { - "name": "blob-ks", - "kind": "azureBlob", - "azureBlobParameters": {}, - } - ] - }, - {"NETWORK_ISOLATION": "false"}, - credential, - "https://search.search.windows.net", - "2025-05-01-preview", - ) - - get.assert_not_called() - - - def test_conversation_upload_disabled_by_default_keeps_single_knowledge_source(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - }, - ) - self.assertEqual(settings["FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED"], "false") - self.assertEqual( - settings["FOUNDRY_IQ_CONVERSATION_KNOWLEDGE_SOURCE_NAME"], - "ragindex-abc123-conv-ks", - ) - - context = { - **settings, - "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", - "EMBEDDING_MODEL_INFO": { - "endpoint": "https://aif-abc123.openai.azure.com/", - "deployment_name": "text-embedding", - "model_name": "text-embedding-3-large", - }, - "GPT_MODEL_INFO": { - "deployment_name": "chat", - "model_name": "gpt-5-nano", - }, - } - - search_definitions = render_json_template("search.j2", context) - self.assertEqual(len(search_definitions["knowledgeSources"]), 1) - self.assertEqual( - search_definitions["knowledgeSources"][0]["kind"], "azureBlob" - ) - self.assertEqual( - len(search_definitions["knowledgeBases"][0]["knowledgeSources"]), 1 - ) - - def test_conversation_upload_enabled_adds_conversational_search_index_source(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - "FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED": "true", - }, - ) - self.assertEqual(settings["FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED"], "true") - - context = { - **settings, - "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", - "EMBEDDING_MODEL_INFO": { - "endpoint": "https://aif-abc123.openai.azure.com/", - "deployment_name": "text-embedding", - "model_name": "text-embedding-3-large", - }, - "GPT_MODEL_INFO": { - "deployment_name": "chat", - "model_name": "gpt-5-nano", - }, - } - - search_definitions = render_json_template("search.j2", context) - knowledge_sources = search_definitions["knowledgeSources"] - self.assertEqual(len(knowledge_sources), 2) - self.assertEqual(knowledge_sources[0]["kind"], "azureBlob") - - conv_source = knowledge_sources[1] - self.assertEqual(conv_source["name"], "ragindex-abc123-conv-ks") - self.assertEqual(conv_source["kind"], "searchIndex") - self.assertEqual( - conv_source["searchIndexParameters"]["searchIndexName"], - "ragindex-abc123", - ) - self.assertEqual( - conv_source["searchIndexParameters"]["semanticConfigurationName"], - "semantic-config", - ) - - kb_sources = search_definitions["knowledgeBases"][0]["knowledgeSources"] - self.assertEqual( - [s["name"] for s in kb_sources], - ["ragindex-abc123-blob-ks", "ragindex-abc123-conv-ks"], - ) - - def test_conversation_upload_not_added_for_search_index_pattern(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - "FOUNDRY_IQ_PATTERN": "searchIndex", - "FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED": "true", - }, - ) - - context = { - **settings, - "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", - "EMBEDDING_MODEL_INFO": { - "endpoint": "https://aif-abc123.openai.azure.com/", - "deployment_name": "text-embedding", - "model_name": "text-embedding-3-large", - }, - "GPT_MODEL_INFO": { - "deployment_name": "chat", - "model_name": "gpt-5-nano", - }, - } - - search_definitions = render_json_template("search.j2", context) - self.assertEqual(len(search_definitions["knowledgeSources"]), 1) - self.assertEqual( - search_definitions["knowledgeSources"][0]["kind"], "searchIndex" - ) - self.assertEqual( - len(search_definitions["knowledgeBases"][0]["knowledgeSources"]), 1 - ) - - -class WorkIqTemplateTests(unittest.TestCase): - """Work IQ is opt-in and default-off. Rendered output must be byte-identical - to the pre-Work IQ template when the feature is disabled. - """ - - def _foundry_iq_context(self, **overrides): - settings_input = { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - } - settings_input.update(overrides) - settings = render_json_template("search.settings.j2", settings_input) - return settings, { - **settings, - "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", - "EMBEDDING_MODEL_INFO": { - "endpoint": "https://aif-abc123.openai.azure.com/", - "deployment_name": "text-embedding", - "model_name": "text-embedding-3-large", - }, - "GPT_MODEL_INFO": { - "deployment_name": "chat", - "model_name": "gpt-5-nano", - }, - } - - def test_settings_defaults_work_iq_disabled_and_empty_name(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - }, - ) - self.assertEqual(settings["WORK_IQ_ENABLED"], "false") - self.assertEqual(settings["WORK_IQ_KNOWLEDGE_SOURCE_NAME"], "") - - def test_work_iq_disabled_by_default_produces_no_workiq_entry(self): - _, context = self._foundry_iq_context() - search_definitions = render_json_template("search.j2", context) - for ks in search_definitions["knowledgeSources"]: - self.assertNotEqual(ks["kind"], "workIQ") - kb_source_names = [ - s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] - ] - self.assertNotIn("work-iq-ks", kb_source_names) - - def test_work_iq_enabled_without_name_produces_no_workiq_entry(self): - _, context = self._foundry_iq_context(WORK_IQ_ENABLED="true") - search_definitions = render_json_template("search.j2", context) - for ks in search_definitions["knowledgeSources"]: - self.assertNotEqual(ks["kind"], "workIQ") - - def test_work_iq_enabled_adds_workiq_knowledge_source_and_kb_reference(self): - _, context = self._foundry_iq_context( - WORK_IQ_ENABLED="true", - WORK_IQ_KNOWLEDGE_SOURCE_NAME="work-iq-ks", - ) - search_definitions = render_json_template("search.j2", context) - - work_iq_sources = [ - ks for ks in search_definitions["knowledgeSources"] if ks["kind"] == "workIQ" - ] - self.assertEqual(len(work_iq_sources), 1) - work_iq = work_iq_sources[0] - self.assertEqual(work_iq["name"], "work-iq-ks") - self.assertEqual(work_iq["kind"], "workIQ") - self.assertIsNone(work_iq["encryptionKey"]) - # Work IQ is service-managed. It must not carry a filterAddOn, blob - # parameters, or search-index parameters. - self.assertNotIn("filterAddOn", work_iq) - self.assertNotIn("azureBlobParameters", work_iq) - self.assertNotIn("searchIndexParameters", work_iq) - - kb_source_names = [ - s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] - ] - self.assertIn("work-iq-ks", kb_source_names) - - def test_work_iq_not_added_when_retrieval_backend_is_ai_search(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "WORK_IQ_ENABLED": "true", - "WORK_IQ_KNOWLEDGE_SOURCE_NAME": "work-iq-ks", - }, - ) - context = { - **settings, - "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", - "EMBEDDING_MODEL_INFO": { - "endpoint": "https://aif-abc123.openai.azure.com/", - "deployment_name": "text-embedding", - "model_name": "text-embedding-3-large", - }, - "GPT_MODEL_INFO": { - "deployment_name": "chat", - "model_name": "gpt-5-nano", - }, - } - search_definitions = render_json_template("search.j2", context) - # ai_search backend never emits knowledge sources at all. - self.assertEqual(search_definitions["knowledgeSources"], []) - self.assertEqual(search_definitions["knowledgeBases"], []) - - def test_settings_defaults_fabric_iq_disabled_and_empty(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - }, - ) - self.assertEqual(settings["FABRIC_IQ_ENABLED"], "false") - self.assertEqual(settings["FABRIC_IQ_KNOWLEDGE_SOURCE_NAME"], "") - self.assertEqual(settings["FABRIC_IQ_WORKSPACE_ID"], "") - self.assertEqual(settings["FABRIC_IQ_ONTOLOGY_ID"], "") - - def test_fabric_iq_disabled_by_default_produces_no_fabric_entry(self): - _, context = self._foundry_iq_context() - search_definitions = render_json_template("search.j2", context) - for ks in search_definitions["knowledgeSources"]: - self.assertNotEqual(ks["kind"], "fabricOntology") - kb_source_names = [ - s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] - ] - self.assertNotIn("fabric-iq-ks", kb_source_names) - - def test_fabric_iq_enabled_without_binding_fields_produces_no_entry(self): - # Enabled + name but missing workspace and ontology ids must not emit. - _, context = self._foundry_iq_context( - FABRIC_IQ_ENABLED="true", - FABRIC_IQ_KNOWLEDGE_SOURCE_NAME="fabric-iq-ks", - ) - search_definitions = render_json_template("search.j2", context) - for ks in search_definitions["knowledgeSources"]: - self.assertNotEqual(ks["kind"], "fabricOntology") - - def test_fabric_iq_enabled_adds_fabric_knowledge_source_and_kb_reference(self): - _, context = self._foundry_iq_context( - FABRIC_IQ_ENABLED="true", - FABRIC_IQ_KNOWLEDGE_SOURCE_NAME="fabric-iq-ks", - FABRIC_IQ_WORKSPACE_ID="ws-guid-1", - FABRIC_IQ_ONTOLOGY_ID="ont-guid-1", - ) - search_definitions = render_json_template("search.j2", context) - - fabric_sources = [ - ks - for ks in search_definitions["knowledgeSources"] - if ks["kind"] == "fabricOntology" - ] - self.assertEqual(len(fabric_sources), 1) - fabric = fabric_sources[0] - self.assertEqual(fabric["name"], "fabric-iq-ks") - self.assertEqual(fabric["kind"], "fabricOntology") - self.assertIsNone(fabric["encryptionKey"]) - # Fabric IQ is service-managed. It must not carry a filterAddOn. - self.assertNotIn("filterAddOn", fabric) - self.assertEqual( - fabric["fabricOntologyParameters"], - {"workspaceId": "ws-guid-1", "ontologyId": "ont-guid-1"}, - ) - - kb_source_names = [ - s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] - ] - self.assertIn("fabric-iq-ks", kb_source_names) - - def test_settings_defaults_fabric_data_agent_disabled_and_empty(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - }, - ) - self.assertEqual(settings["FABRIC_DATA_AGENT_ENABLED"], "false") - self.assertEqual(settings["FABRIC_DATA_AGENT_KNOWLEDGE_SOURCE_NAME"], "") - self.assertEqual(settings["FABRIC_DATA_AGENT_WORKSPACE_ID"], "") - self.assertEqual(settings["FABRIC_DATA_AGENT_DATA_AGENT_ID"], "") - - def test_fabric_data_agent_disabled_by_default_produces_no_entry(self): - _, context = self._foundry_iq_context() - search_definitions = render_json_template("search.j2", context) - for ks in search_definitions["knowledgeSources"]: - self.assertNotEqual(ks["kind"], "fabricDataAgent") - kb_source_names = [ - s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] - ] - self.assertNotIn("fabric-data-agent-ks", kb_source_names) - - def test_fabric_data_agent_enabled_without_binding_fields_produces_no_entry(self): - # Enabled + name but missing workspace and data agent ids must not emit. - _, context = self._foundry_iq_context( - FABRIC_DATA_AGENT_ENABLED="true", - FABRIC_DATA_AGENT_KNOWLEDGE_SOURCE_NAME="fabric-data-agent-ks", - ) - search_definitions = render_json_template("search.j2", context) - for ks in search_definitions["knowledgeSources"]: - self.assertNotEqual(ks["kind"], "fabricDataAgent") - - def test_fabric_data_agent_enabled_adds_knowledge_source_and_kb_reference(self): - _, context = self._foundry_iq_context( - FABRIC_DATA_AGENT_ENABLED="true", - FABRIC_DATA_AGENT_KNOWLEDGE_SOURCE_NAME="fabric-data-agent-ks", - FABRIC_DATA_AGENT_WORKSPACE_ID="ws-guid-2", - FABRIC_DATA_AGENT_DATA_AGENT_ID="da-guid-2", - ) - search_definitions = render_json_template("search.j2", context) - - agent_sources = [ - ks - for ks in search_definitions["knowledgeSources"] - if ks["kind"] == "fabricDataAgent" - ] - self.assertEqual(len(agent_sources), 1) - agent = agent_sources[0] - self.assertEqual(agent["name"], "fabric-data-agent-ks") - self.assertEqual(agent["kind"], "fabricDataAgent") - self.assertIsNone(agent["encryptionKey"]) - # Fabric Data Agent is service-managed. It must not carry a filterAddOn. - self.assertNotIn("filterAddOn", agent) - self.assertEqual( - agent["fabricDataAgentParameters"], - {"workspaceId": "ws-guid-2", "dataAgentId": "da-guid-2"}, - ) - - kb_source_names = [ - s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] - ] - self.assertIn("fabric-data-agent-ks", kb_source_names) - - def test_settings_defaults_web_grounding_disabled_and_empty(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - }, - ) - self.assertEqual(settings["WEB_GROUNDING_ENABLED"], "false") - self.assertEqual(settings["WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME"], "") - self.assertEqual(settings["WEB_GROUNDING_ALLOWED_DOMAINS"], "") - self.assertEqual(settings["WEB_GROUNDING_BLOCKED_DOMAINS"], "") - - def test_web_grounding_disabled_by_default_produces_no_entry(self): - _, context = self._foundry_iq_context() - search_definitions = render_json_template("search.j2", context) - for ks in search_definitions["knowledgeSources"]: - self.assertNotEqual(ks["kind"], "web") - kb_source_names = [ - s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] - ] - self.assertNotIn("web-ks", kb_source_names) - - def test_web_grounding_enabled_without_name_produces_no_entry(self): - _, context = self._foundry_iq_context( - WEB_GROUNDING_ENABLED="true", - WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME="", - ) - search_definitions = render_json_template("search.j2", context) - for ks in search_definitions["knowledgeSources"]: - self.assertNotEqual(ks["kind"], "web") - - def test_web_grounding_enabled_adds_knowledge_source_and_kb_reference(self): - _, context = self._foundry_iq_context( - WEB_GROUNDING_ENABLED="true", - WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME="web-ks", - WEB_GROUNDING_ALLOWED_DOMAINS="Learn.Microsoft.com, azure.microsoft.com", - WEB_GROUNDING_BLOCKED_DOMAINS="example.com", - ) - search_definitions = render_json_template("search.j2", context) - - web_sources = [ - ks - for ks in search_definitions["knowledgeSources"] - if ks["kind"] == "web" - ] - self.assertEqual(len(web_sources), 1) - web = web_sources[0] - self.assertEqual(web["name"], "web-ks") - self.assertEqual(web["kind"], "web") - self.assertIsNone(web["encryptionKey"]) - # Public data - no ACL, no filterAddOn. - self.assertNotIn("filterAddOn", web) - self.assertEqual( - web["webParameters"], - { - "domains": { - "allowedDomains": ["learn.microsoft.com", "azure.microsoft.com"], - "blockedDomains": ["example.com"], - } - }, - ) - - kb_source_names = [ - s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] - ] - self.assertIn("web-ks", kb_source_names) - - def test_web_grounding_enabled_without_domains_emits_empty_lists(self): - _, context = self._foundry_iq_context( - WEB_GROUNDING_ENABLED="true", - WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME="web-ks", - ) - search_definitions = render_json_template("search.j2", context) - web_sources = [ - ks - for ks in search_definitions["knowledgeSources"] - if ks["kind"] == "web" - ] - self.assertEqual(len(web_sources), 1) - self.assertEqual( - web_sources[0]["webParameters"], - {"domains": {"allowedDomains": [], "blockedDomains": []}}, - ) - - -class WorkIqPreflightTests(unittest.TestCase): - def test_filter_work_iq_sources_no_op_when_disabled(self): - defs = { - "knowledgeSources": [ - {"name": "blob-ks", "kind": "azureBlob"}, - ], - "knowledgeBases": [ - {"name": "kb", "knowledgeSources": [{"name": "blob-ks"}]}, - ], - } - setup.filter_work_iq_sources( - defs, - {"RETRIEVAL_BACKEND": "foundry_iq", "WORK_IQ_ENABLED": "false"}, - Mock(), - ) - self.assertEqual(defs["knowledgeSources"], [{"name": "blob-ks", "kind": "azureBlob"}]) - self.assertEqual(defs["knowledgeBases"][0]["knowledgeSources"], [{"name": "blob-ks"}]) - - def test_filter_work_iq_sources_removes_source_when_not_consented(self): - defs = { - "knowledgeSources": [ - {"name": "blob-ks", "kind": "azureBlob"}, - {"name": "work-iq-ks", "kind": "workIQ"}, - ], - "knowledgeBases": [ - { - "name": "kb", - "knowledgeSources": [{"name": "blob-ks"}, {"name": "work-iq-ks"}], - } - ], - } - credential = Mock() - with patch.object(setup, "check_work_iq_admin_consent", return_value=False): - setup.filter_work_iq_sources( - defs, - { - "RETRIEVAL_BACKEND": "foundry_iq", - "WORK_IQ_ENABLED": "true", - "WORK_IQ_KNOWLEDGE_SOURCE_NAME": "work-iq-ks", - }, - credential, - ) - self.assertEqual([ks["name"] for ks in defs["knowledgeSources"]], ["blob-ks"]) - self.assertEqual( - [ref["name"] for ref in defs["knowledgeBases"][0]["knowledgeSources"]], - ["blob-ks"], - ) - - def test_filter_work_iq_sources_keeps_source_when_consented(self): - defs = { - "knowledgeSources": [ - {"name": "work-iq-ks", "kind": "workIQ"}, - ], - "knowledgeBases": [ - {"name": "kb", "knowledgeSources": [{"name": "work-iq-ks"}]} - ], - } - with patch.object(setup, "check_work_iq_admin_consent", return_value=True): - setup.filter_work_iq_sources( - defs, - { - "RETRIEVAL_BACKEND": "foundry_iq", - "WORK_IQ_ENABLED": "true", - "WORK_IQ_KNOWLEDGE_SOURCE_NAME": "work-iq-ks", - }, - Mock(), - ) - self.assertEqual([ks["name"] for ks in defs["knowledgeSources"]], ["work-iq-ks"]) - - def test_filter_work_iq_sources_removes_source_when_preflight_inconclusive(self): - defs = { - "knowledgeSources": [{"name": "work-iq-ks", "kind": "workIQ"}], - "knowledgeBases": [ - {"name": "kb", "knowledgeSources": [{"name": "work-iq-ks"}]} - ], - } - with patch.object(setup, "check_work_iq_admin_consent", return_value=None): - setup.filter_work_iq_sources( - defs, - { - "RETRIEVAL_BACKEND": "foundry_iq", - "WORK_IQ_ENABLED": "true", - "WORK_IQ_KNOWLEDGE_SOURCE_NAME": "work-iq-ks", - }, - Mock(), - ) - self.assertEqual(defs["knowledgeSources"], []) - self.assertEqual(defs["knowledgeBases"][0]["knowledgeSources"], []) - - -if __name__ == "__main__": - unittest.main() +import inspect +import io +import itertools +import json +import os +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest.mock import Mock, patch + +from jinja2 import Environment, FileSystemLoader, StrictUndefined + +from config.search import setup +from config.search import foundry_iq_mcp_setup as mcp_setup + + +TEMPLATE_DIR = Path(__file__).resolve().parents[1] +REPO_ROOT = Path(__file__).resolve().parents[3] +FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" +MCP_RUNTIME_CONTRACT_VERSION = "v3.7.0" + + +def render_json_template(template_name, context): + env = Environment( + loader=FileSystemLoader(TEMPLATE_DIR), + undefined=StrictUndefined, + keep_trailing_newline=True, + ) + rendered = env.get_template(template_name).render(**context) + return json.loads(rendered) + + +class FoundryIqTemplateTests(unittest.TestCase): + def test_settings_template_derives_search_index_before_dependent_defaults(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_PROJECT_ENDPOINT": "https://aif-abc123.services.ai.azure.com/api/projects/proj", + }, + ) + + self.assertEqual(settings["SEARCH_RAG_INDEX_NAME"], "ragindex-abc123") + self.assertEqual(settings["KNOWLEDGE_BASE_NAME"], "ragindex-abc123-rag-kb") + self.assertEqual(settings["FOUNDRY_IQ_KNOWLEDGE_SOURCE_NAME"], "ragindex-abc123-blob-ks") + self.assertEqual( + settings["FOUNDRY_IQ_AI_SERVICES_ENDPOINT"], + "https://aif-abc123.services.ai.azure.com/", + ) + + def test_standard_blob_knowledge_source_includes_ai_services_endpoint_without_unsupported_chat_model(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + "FOUNDRY_IQ_CONTENT_EXTRACTION_MODE": "standard", + }, + ) + context = { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5-nano", + }, + } + + search_definitions = render_json_template("search.j2", context) + knowledge_source = search_definitions["knowledgeSources"][0] + ingestion_parameters = knowledge_source["azureBlobParameters"]["ingestionParameters"] + + self.assertEqual(ingestion_parameters["contentExtractionMode"], "standard") + self.assertEqual( + ingestion_parameters["aiServices"]["uri"], + "https://aif-abc123.services.ai.azure.com/", + ) + self.assertIsNone(ingestion_parameters["chatCompletionModel"]) + + def test_standard_blob_knowledge_source_includes_supported_chat_model(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + "FOUNDRY_IQ_CONTENT_EXTRACTION_MODE": "standard", + }, + ) + context = { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5.2", + }, + } + + search_definitions = render_json_template("search.j2", context) + knowledge_source = search_definitions["knowledgeSources"][0] + ingestion_parameters = knowledge_source["azureBlobParameters"]["ingestionParameters"] + + self.assertEqual( + ingestion_parameters["chatCompletionModel"]["azureOpenAIParameters"]["resourceUri"], + ingestion_parameters["aiServices"]["uri"], + ) + self.assertEqual( + ingestion_parameters["chatCompletionModel"]["azureOpenAIParameters"]["modelName"], + "gpt-5.2", + ) + + def test_network_isolated_blob_knowledge_source_sets_generated_indexer_private(self): + credential = Mock() + credential.get_token.return_value.token = "token" + get_response = Mock() + get_response.status_code = 200 + get_response.json.return_value = { + "@odata.context": "https://search/$metadata#indexers/$entity", + "name": "blob-ks-indexer", + "dataSourceName": "blob-ks-datasource", + "targetIndexName": "blob-ks-index", + "parameters": { + "configuration": { + "dataToExtract": "contentAndMetadata", + } + }, + } + put_response = Mock() + put_response.status_code = 200 + + with patch.object(setup.requests, "get", return_value=get_response), patch.object( + setup.requests, "put", return_value=put_response + ) as put: + setup.enforce_private_execution_for_generated_indexers( + { + "knowledgeSources": [ + { + "name": "blob-ks", + "kind": "azureBlob", + "azureBlobParameters": {}, + } + ] + }, + {"NETWORK_ISOLATION": "true"}, + credential, + "https://search.search.windows.net", + "2025-05-01-preview", + ) + + body = put.call_args.kwargs["json"] + self.assertNotIn("@odata.context", body) + self.assertEqual(body["parameters"]["configuration"]["executionEnvironment"], "Private") + + def test_non_network_isolated_setup_does_not_update_generated_indexer(self): + credential = Mock() + + with patch.object(setup.requests, "get") as get: + setup.enforce_private_execution_for_generated_indexers( + { + "knowledgeSources": [ + { + "name": "blob-ks", + "kind": "azureBlob", + "azureBlobParameters": {}, + } + ] + }, + {"NETWORK_ISOLATION": "false"}, + credential, + "https://search.search.windows.net", + "2025-05-01-preview", + ) + + get.assert_not_called() + + + def test_conversation_upload_disabled_by_default_keeps_single_knowledge_source(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + }, + ) + self.assertEqual(settings["FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED"], "false") + self.assertEqual( + settings["FOUNDRY_IQ_CONVERSATION_KNOWLEDGE_SOURCE_NAME"], + "ragindex-abc123-conv-ks", + ) + + context = { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5-nano", + }, + } + + search_definitions = render_json_template("search.j2", context) + self.assertEqual(len(search_definitions["knowledgeSources"]), 1) + self.assertEqual( + search_definitions["knowledgeSources"][0]["kind"], "azureBlob" + ) + self.assertEqual( + len(search_definitions["knowledgeBases"][0]["knowledgeSources"]), 1 + ) + + def test_conversation_upload_enabled_adds_conversational_search_index_source(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + "FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED": "true", + }, + ) + self.assertEqual(settings["FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED"], "true") + + context = { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5-nano", + }, + } + + search_definitions = render_json_template("search.j2", context) + knowledge_sources = search_definitions["knowledgeSources"] + self.assertEqual(len(knowledge_sources), 2) + self.assertEqual(knowledge_sources[0]["kind"], "azureBlob") + + conv_source = knowledge_sources[1] + self.assertEqual(conv_source["name"], "ragindex-abc123-conv-ks") + self.assertEqual(conv_source["kind"], "searchIndex") + self.assertEqual( + conv_source["searchIndexParameters"]["searchIndexName"], + "ragindex-abc123", + ) + self.assertEqual( + conv_source["searchIndexParameters"]["semanticConfigurationName"], + "semantic-config", + ) + + kb_sources = search_definitions["knowledgeBases"][0]["knowledgeSources"] + self.assertEqual( + [s["name"] for s in kb_sources], + ["ragindex-abc123-blob-ks", "ragindex-abc123-conv-ks"], + ) + + def test_conversation_upload_not_added_for_search_index_pattern(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + "FOUNDRY_IQ_PATTERN": "searchIndex", + "FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED": "true", + }, + ) + + context = { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5-nano", + }, + } + + search_definitions = render_json_template("search.j2", context) + self.assertEqual(len(search_definitions["knowledgeSources"]), 1) + self.assertEqual( + search_definitions["knowledgeSources"][0]["kind"], "searchIndex" + ) + self.assertEqual( + len(search_definitions["knowledgeBases"][0]["knowledgeSources"]), 1 + ) + + +class WorkIqTemplateTests(unittest.TestCase): + """Work IQ is opt-in and default-off. Rendered output must be byte-identical + to the pre-Work IQ template when the feature is disabled. + """ + + def _foundry_iq_context(self, **overrides): + settings_input = { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + } + settings_input.update(overrides) + settings = render_json_template("search.settings.j2", settings_input) + return settings, { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5-nano", + }, + } + + def test_settings_defaults_work_iq_disabled_and_empty_name(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + }, + ) + self.assertEqual(settings["WORK_IQ_ENABLED"], "false") + self.assertEqual(settings["WORK_IQ_KNOWLEDGE_SOURCE_NAME"], "") + + def test_work_iq_disabled_by_default_produces_no_workiq_entry(self): + _, context = self._foundry_iq_context() + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "workIQ") + kb_source_names = [ + s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] + ] + self.assertNotIn("work-iq-ks", kb_source_names) + + def test_work_iq_enabled_without_name_produces_no_workiq_entry(self): + _, context = self._foundry_iq_context(WORK_IQ_ENABLED="true") + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "workIQ") + + def test_work_iq_enabled_adds_workiq_knowledge_source_and_kb_reference(self): + _, context = self._foundry_iq_context( + WORK_IQ_ENABLED="true", + WORK_IQ_KNOWLEDGE_SOURCE_NAME="work-iq-ks", + ) + search_definitions = render_json_template("search.j2", context) + + work_iq_sources = [ + ks for ks in search_definitions["knowledgeSources"] if ks["kind"] == "workIQ" + ] + self.assertEqual(len(work_iq_sources), 1) + work_iq = work_iq_sources[0] + self.assertEqual(work_iq["name"], "work-iq-ks") + self.assertEqual(work_iq["kind"], "workIQ") + self.assertIsNone(work_iq["encryptionKey"]) + # Work IQ is service-managed. It must not carry a filterAddOn, blob + # parameters, or search-index parameters. + self.assertNotIn("filterAddOn", work_iq) + self.assertNotIn("azureBlobParameters", work_iq) + self.assertNotIn("searchIndexParameters", work_iq) + + kb_source_names = [ + s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] + ] + self.assertIn("work-iq-ks", kb_source_names) + + def test_work_iq_not_added_when_retrieval_backend_is_ai_search(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "WORK_IQ_ENABLED": "true", + "WORK_IQ_KNOWLEDGE_SOURCE_NAME": "work-iq-ks", + }, + ) + context = { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5-nano", + }, + } + search_definitions = render_json_template("search.j2", context) + # ai_search backend never emits knowledge sources at all. + self.assertEqual(search_definitions["knowledgeSources"], []) + self.assertEqual(search_definitions["knowledgeBases"], []) + + def test_settings_defaults_fabric_iq_disabled_and_empty(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + }, + ) + self.assertEqual(settings["FABRIC_IQ_ENABLED"], "false") + self.assertEqual(settings["FABRIC_IQ_KNOWLEDGE_SOURCE_NAME"], "") + self.assertEqual(settings["FABRIC_IQ_WORKSPACE_ID"], "") + self.assertEqual(settings["FABRIC_IQ_ONTOLOGY_ID"], "") + + def test_fabric_iq_disabled_by_default_produces_no_fabric_entry(self): + _, context = self._foundry_iq_context() + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "fabricOntology") + kb_source_names = [ + s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] + ] + self.assertNotIn("fabric-iq-ks", kb_source_names) + + def test_fabric_iq_enabled_without_binding_fields_produces_no_entry(self): + # Enabled + name but missing workspace and ontology ids must not emit. + _, context = self._foundry_iq_context( + FABRIC_IQ_ENABLED="true", + FABRIC_IQ_KNOWLEDGE_SOURCE_NAME="fabric-iq-ks", + ) + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "fabricOntology") + + def test_fabric_iq_enabled_adds_fabric_knowledge_source_and_kb_reference(self): + _, context = self._foundry_iq_context( + FABRIC_IQ_ENABLED="true", + FABRIC_IQ_KNOWLEDGE_SOURCE_NAME="fabric-iq-ks", + FABRIC_IQ_WORKSPACE_ID="ws-guid-1", + FABRIC_IQ_ONTOLOGY_ID="ont-guid-1", + ) + search_definitions = render_json_template("search.j2", context) + + fabric_sources = [ + ks + for ks in search_definitions["knowledgeSources"] + if ks["kind"] == "fabricOntology" + ] + self.assertEqual(len(fabric_sources), 1) + fabric = fabric_sources[0] + self.assertEqual(fabric["name"], "fabric-iq-ks") + self.assertEqual(fabric["kind"], "fabricOntology") + self.assertIsNone(fabric["encryptionKey"]) + # Fabric IQ is service-managed. It must not carry a filterAddOn. + self.assertNotIn("filterAddOn", fabric) + self.assertEqual( + fabric["fabricOntologyParameters"], + {"workspaceId": "ws-guid-1", "ontologyId": "ont-guid-1"}, + ) + + kb_source_names = [ + s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] + ] + self.assertIn("fabric-iq-ks", kb_source_names) + + def test_settings_defaults_fabric_data_agent_disabled_and_empty(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + }, + ) + self.assertEqual(settings["FABRIC_DATA_AGENT_ENABLED"], "false") + self.assertEqual(settings["FABRIC_DATA_AGENT_KNOWLEDGE_SOURCE_NAME"], "") + self.assertEqual(settings["FABRIC_DATA_AGENT_WORKSPACE_ID"], "") + self.assertEqual(settings["FABRIC_DATA_AGENT_DATA_AGENT_ID"], "") + + def test_fabric_data_agent_disabled_by_default_produces_no_entry(self): + _, context = self._foundry_iq_context() + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "fabricDataAgent") + kb_source_names = [ + s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] + ] + self.assertNotIn("fabric-data-agent-ks", kb_source_names) + + def test_fabric_data_agent_enabled_without_binding_fields_produces_no_entry(self): + # Enabled + name but missing workspace and data agent ids must not emit. + _, context = self._foundry_iq_context( + FABRIC_DATA_AGENT_ENABLED="true", + FABRIC_DATA_AGENT_KNOWLEDGE_SOURCE_NAME="fabric-data-agent-ks", + ) + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "fabricDataAgent") + + def test_fabric_data_agent_enabled_adds_knowledge_source_and_kb_reference(self): + _, context = self._foundry_iq_context( + FABRIC_DATA_AGENT_ENABLED="true", + FABRIC_DATA_AGENT_KNOWLEDGE_SOURCE_NAME="fabric-data-agent-ks", + FABRIC_DATA_AGENT_WORKSPACE_ID="ws-guid-2", + FABRIC_DATA_AGENT_DATA_AGENT_ID="da-guid-2", + ) + search_definitions = render_json_template("search.j2", context) + + agent_sources = [ + ks + for ks in search_definitions["knowledgeSources"] + if ks["kind"] == "fabricDataAgent" + ] + self.assertEqual(len(agent_sources), 1) + agent = agent_sources[0] + self.assertEqual(agent["name"], "fabric-data-agent-ks") + self.assertEqual(agent["kind"], "fabricDataAgent") + self.assertIsNone(agent["encryptionKey"]) + # Fabric Data Agent is service-managed. It must not carry a filterAddOn. + self.assertNotIn("filterAddOn", agent) + self.assertEqual( + agent["fabricDataAgentParameters"], + {"workspaceId": "ws-guid-2", "dataAgentId": "da-guid-2"}, + ) + + kb_source_names = [ + s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] + ] + self.assertIn("fabric-data-agent-ks", kb_source_names) + + def test_settings_defaults_web_grounding_disabled_and_empty(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + }, + ) + self.assertEqual(settings["WEB_GROUNDING_ENABLED"], "false") + self.assertEqual(settings["WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME"], "") + self.assertEqual(settings["WEB_GROUNDING_ALLOWED_DOMAINS"], "") + self.assertEqual(settings["WEB_GROUNDING_BLOCKED_DOMAINS"], "") + + def test_web_grounding_disabled_by_default_produces_no_entry(self): + _, context = self._foundry_iq_context() + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "web") + kb_source_names = [ + s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] + ] + self.assertNotIn("web-ks", kb_source_names) + + def test_web_grounding_enabled_without_name_produces_no_entry(self): + _, context = self._foundry_iq_context( + WEB_GROUNDING_ENABLED="true", + WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME="", + ) + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "web") + + def test_web_grounding_enabled_adds_knowledge_source_and_kb_reference(self): + _, context = self._foundry_iq_context( + WEB_GROUNDING_ENABLED="true", + WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME="web-ks", + WEB_GROUNDING_ALLOWED_DOMAINS="Learn.Microsoft.com, azure.microsoft.com", + WEB_GROUNDING_BLOCKED_DOMAINS="example.com", + ) + search_definitions = render_json_template("search.j2", context) + + web_sources = [ + ks + for ks in search_definitions["knowledgeSources"] + if ks["kind"] == "web" + ] + self.assertEqual(len(web_sources), 1) + web = web_sources[0] + self.assertEqual(web["name"], "web-ks") + self.assertEqual(web["kind"], "web") + self.assertIsNone(web["encryptionKey"]) + # Public data - no ACL, no filterAddOn. + self.assertNotIn("filterAddOn", web) + self.assertEqual( + web["webParameters"], + { + "domains": { + "allowedDomains": ["learn.microsoft.com", "azure.microsoft.com"], + "blockedDomains": ["example.com"], + } + }, + ) + + kb_source_names = [ + s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] + ] + self.assertIn("web-ks", kb_source_names) + + def test_web_grounding_enabled_without_domains_emits_empty_lists(self): + _, context = self._foundry_iq_context( + WEB_GROUNDING_ENABLED="true", + WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME="web-ks", + ) + search_definitions = render_json_template("search.j2", context) + web_sources = [ + ks + for ks in search_definitions["knowledgeSources"] + if ks["kind"] == "web" + ] + self.assertEqual(len(web_sources), 1) + self.assertEqual( + web_sources[0]["webParameters"], + {"domains": {"allowedDomains": [], "blockedDomains": []}}, + ) + + +class FoundryIqMcpTemplateTests(unittest.TestCase): + """Generic MCP Server knowledge sources are opt-in and default-off. + Rendered output must be unchanged when the feature is disabled. + """ + + def _foundry_iq_context(self, **overrides): + settings_input = { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + } + settings_input.update(overrides) + settings = render_json_template("search.settings.j2", settings_input) + return settings, { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5-nano", + }, + } + + def _mcp_source(self, **overrides): + source = { + "name": "monitor-mcp-ks", + "description": "Azure Monitor MCP", + "serverURL": "https://monitor-mcp.contoso.com/mcp", + "tools": [ + { + "name": "query_logs", + "outputParsing": {"kind": "auto"}, + "inclusionMode": "reranked", + "maxOutputTokens": 4096, + } + ], + } + source.update(overrides) + return source + + def _query_headers(self): + return [ + { + "name": "Authorization", + "valueFrom": { + "kind": "managedIdentity", + "scope": "api://monitor/.default", + }, + }, + { + "name": "x-user-token", + "valueFrom": { + "kind": "obo", + "scope": "api://monitor/user_impersonation", + }, + }, + { + "name": "x-api-key", + "valueFrom": { + "kind": "keyVaultSecret", + "secretName": "monitor-api-key", + }, + }, + {"name": "x-no-auth", "valueFrom": {"kind": "none"}}, + ] + + def test_settings_defaults_mcp_disabled_and_empty(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + }, + ) + self.assertEqual(settings["FOUNDRY_IQ_MCP_ENABLED"], "false") + self.assertEqual(settings["FOUNDRY_IQ_MCP_SOURCES_JSON"], []) + self.assertEqual(settings["FOUNDRY_IQ_MCP_REASONING_EFFORT"], "low") + self.assertEqual(settings["FOUNDRY_IQ_MCP_TRUSTED_HOSTS"], "") + self.assertEqual(settings["FOUNDRY_IQ_MCP_LOG_TOOL_ARGUMENTS"], "false") + + def test_mcp_disabled_by_default_produces_no_entry_and_preserves_minimal_reasoning(self): + _, context = self._foundry_iq_context() + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "mcpServer") + kb = search_definitions["knowledgeBases"][0] + self.assertNotIn("monitor-mcp-ks", [s["name"] for s in kb["knowledgeSources"]]) + self.assertEqual(kb["models"], []) + self.assertEqual(kb["retrievalReasoningEffort"], {"kind": "minimal"}) + + def test_mcp_enabled_without_sources_produces_no_entry(self): + _, context = self._foundry_iq_context(FOUNDRY_IQ_MCP_ENABLED="true") + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "mcpServer") + kb = search_definitions["knowledgeBases"][0] + self.assertEqual(kb["models"], []) + self.assertEqual(kb["retrievalReasoningEffort"], {"kind": "minimal"}) + + def test_mcp_enabled_registers_source_with_kb_planning_model_and_reasoning(self): + _, context = self._foundry_iq_context( + FOUNDRY_IQ_MCP_ENABLED="true", + FOUNDRY_IQ_MCP_REASONING_EFFORT="medium", + ) + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = [ + self._mcp_source( + failOnError=False, + maxOutputDocuments=25, + queryHeaders=self._query_headers(), + ) + ] + search_definitions = render_json_template("search.j2", context) + + mcp_sources = [ks for ks in search_definitions["knowledgeSources"] if ks["kind"] == "mcpServer"] + self.assertEqual(len(mcp_sources), 1) + source = mcp_sources[0] + self.assertEqual(source["name"], "monitor-mcp-ks") + self.assertIsNone(source["encryptionKey"]) + self.assertEqual( + source["mcpServerParameters"], + { + "serverURL": "https://monitor-mcp.contoso.com/mcp", + "tools": [ + { + "name": "query_logs", + "outputParsing": {"kind": "auto"}, + "inclusionMode": "reranked", + "maxOutputTokens": 4096, + } + ], + }, + ) + # Runtime query-header metadata must never be forwarded into Search + # registration or knowledge-base retrieve parameters. + self.assertNotIn("queryHeaders", json.dumps(source)) + self.assertNotIn("queryHeaders", json.dumps(search_definitions["knowledgeBases"])) + self.assertNotIn("auth", source["mcpServerParameters"]) + self.assertNotIn("authentication", source["mcpServerParameters"]) + self.assertNotIn("authIdentity", source["mcpServerParameters"]) + + kb = search_definitions["knowledgeBases"][0] + self.assertIn("monitor-mcp-ks", [s["name"] for s in kb["knowledgeSources"]]) + # Never emit alwaysQuerySource for MCP knowledge source references. + mcp_ref = next(s for s in kb["knowledgeSources"] if s["name"] == "monitor-mcp-ks") + self.assertEqual(mcp_ref, {"name": "monitor-mcp-ks"}) + + self.assertEqual( + kb["models"], + [ + { + "kind": "azureOpenAI", + "azureOpenAIParameters": { + "resourceUri": context["FOUNDRY_IQ_AI_SERVICES_ENDPOINT"], + "deploymentId": "chat", + "modelName": "gpt-5-nano", + }, + } + ], + ) + self.assertEqual(kb["retrievalReasoningEffort"], {"kind": "medium"}) + + def test_mcp_tool_output_parsing_kinds_render_as_official_rest_shapes(self): + """auto/none/split render as {"kind": "..."}; json nests documentsPath + under jsonParameters, matching Search API 2026-05-01-preview + (McpServerOutputParsing / McpServerJsonOutputParsing).""" + _, context = self._foundry_iq_context(FOUNDRY_IQ_MCP_ENABLED="true") + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = [ + self._mcp_source( + tools=[ + { + "name": "auto_tool", + "outputParsing": {"kind": "auto"}, + "inclusionMode": "reranked", + "maxOutputTokens": 1024, + }, + { + "name": "none_tool", + "outputParsing": {"kind": "none"}, + "inclusionMode": "reranked", + "maxOutputTokens": 1024, + }, + { + "name": "split_tool", + "outputParsing": { + "kind": "split", + "splitParameters": { + "textSplitMode": "pages", + "maximumPageLength": 4000, + "pageOverlapLength": 500, + "maximumPagesToTake": 10, + "defaultLanguageCode": "en", + }, + }, + "inclusionMode": "reranked", + "maxOutputTokens": 1024, + }, + { + "name": "json_tool", + "outputParsing": { + "kind": "json", + "jsonParameters": { + "documentsPath": "$.results", + "includeContext": True, + }, + }, + "inclusionMode": "always", + "maxOutputTokens": 2048, + }, + ] + ) + ] + search_definitions = render_json_template("search.j2", context) + tools = search_definitions["knowledgeSources"][-1]["mcpServerParameters"]["tools"] + rendered = {tool["name"]: tool for tool in tools} + + self.assertEqual(rendered["auto_tool"]["outputParsing"], {"kind": "auto"}) + self.assertEqual(rendered["none_tool"]["outputParsing"], {"kind": "none"}) + self.assertEqual( + rendered["split_tool"]["outputParsing"], + { + "kind": "split", + "splitParameters": { + "textSplitMode": "pages", + "maximumPageLength": 4000, + "pageOverlapLength": 500, + "maximumPagesToTake": 10, + "defaultLanguageCode": "en", + }, + }, + ) + self.assertEqual( + rendered["json_tool"]["outputParsing"], + { + "kind": "json", + "jsonParameters": { + "documentsPath": "$.results", + "includeContext": True, + }, + }, + ) + # documentsPath must never be a tool-level sibling key. + for tool in tools: + self.assertNotIn("documentsPath", tool) + + def test_canonical_fixture_shared_with_runtime_validates_and_renders(self): + _, context = self._foundry_iq_context(FOUNDRY_IQ_MCP_ENABLED="true") + manifest = json.loads((REPO_ROOT / "manifest.json").read_text(encoding="utf-8")) + orchestrator = next( + component + for component in manifest["components"] + if component["name"] == "gpt-rag-orchestrator" + ) + self.assertEqual(orchestrator["tag"], MCP_RUNTIME_CONTRACT_VERSION) + + fixture_path = ( + FIXTURE_DIR + / f"foundry_iq_mcp_canonical_source_{MCP_RUNTIME_CONTRACT_VERSION.removeprefix('v').replace('.', '_')}.json" + ) + source = json.loads(fixture_path.read_text(encoding="utf-8")) + context["FOUNDRY_IQ_MCP_TRUSTED_HOSTS"] = "mcp.contoso.com" + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = [source] + + mcp_setup.validate_foundry_iq_mcp_settings(context) + self.assertEqual(context["FOUNDRY_IQ_MCP_SOURCES_JSON"], [source]) + + search_definitions = render_json_template("search.j2", context) + rendered_source = next( + knowledge_source + for knowledge_source in search_definitions["knowledgeSources"] + if knowledge_source["name"] == source["name"] + ) + self.assertEqual( + rendered_source["mcpServerParameters"], + { + "serverURL": source["serverURL"], + "tools": source["tools"], + }, + ) + + def test_multiple_mcp_sources_all_registered_and_referenced(self): + _, context = self._foundry_iq_context(FOUNDRY_IQ_MCP_ENABLED="true") + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = [ + self._mcp_source(name="monitor-mcp-ks", serverURL="https://monitor-mcp.contoso.com/mcp"), + self._mcp_source(name="billing-mcp-ks", serverURL="https://billing-mcp.contoso.com/mcp"), + ] + search_definitions = render_json_template("search.j2", context) + + mcp_names = [ + ks["name"] for ks in search_definitions["knowledgeSources"] if ks["kind"] == "mcpServer" + ] + self.assertEqual(mcp_names, ["monitor-mcp-ks", "billing-mcp-ks"]) + + kb_names = [s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"]] + self.assertIn("monitor-mcp-ks", kb_names) + self.assertIn("billing-mcp-ks", kb_names) + + def test_mcp_coexists_with_web_grounding_and_sharepoint_indexed(self): + _, context = self._foundry_iq_context( + FOUNDRY_IQ_MCP_ENABLED="true", + WEB_GROUNDING_ENABLED="true", + WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME="web-ks", + SHAREPOINT_INDEXED_ENABLED="true", + SHAREPOINT_INDEXED_KNOWLEDGE_SOURCE_NAME="sp-ks", + SHAREPOINT_INDEXED_INDEX_NAME="sp-index", + SHAREPOINT_INDEXED_SITE_URL="https://contoso.sharepoint.com/sites/eng", + SHAREPOINT_INDEXED_TENANT_ID="tenant-guid", + ) + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = [self._mcp_source()] + search_definitions = render_json_template("search.j2", context) + + kinds = {ks["kind"] for ks in search_definitions["knowledgeSources"]} + self.assertTrue({"azureBlob", "web", "indexedSharePoint", "mcpServer"}.issubset(kinds)) + + kb_names = [s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"]] + self.assertIn("web-ks", kb_names) + self.assertIn("sp-ks", kb_names) + self.assertIn("monitor-mcp-ks", kb_names) + + def test_rendering_is_deterministic(self): + _, context = self._foundry_iq_context(FOUNDRY_IQ_MCP_ENABLED="true") + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = [ + self._mcp_source(name="monitor-mcp-ks"), + self._mcp_source(name="billing-mcp-ks", serverURL="https://billing-mcp.contoso.com/mcp"), + ] + first = render_json_template("search.j2", context) + second = render_json_template("search.j2", context) + self.assertEqual(first, second) + self.assertEqual(json.dumps(first, sort_keys=True), json.dumps(second, sort_keys=True)) + + +class FoundryIqMcpValidationTests(unittest.TestCase): + """Unit tests for the fail-closed validation in foundry_iq_mcp_setup.""" + + def _context(self, sources=None, **overrides): + context = { + "RETRIEVAL_BACKEND": "foundry_iq", + "FOUNDRY_IQ_MCP_ENABLED": "true", + "FOUNDRY_IQ_MCP_SOURCES_JSON": sources if sources is not None else [], + "FOUNDRY_IQ_MCP_TRUSTED_HOSTS": "monitor-mcp.contoso.com", + "FOUNDRY_IQ_MCP_REASONING_EFFORT": "low", + "GPT_MODEL_INFO": {"deployment_name": "chat", "model_name": "gpt-5-nano"}, + "FOUNDRY_IQ_AI_SERVICES_ENDPOINT": "https://aif-abc123.services.ai.azure.com/", + } + context.update(overrides) + return context + + def _source(self, **overrides): + source = { + "name": "monitor-mcp-ks", + "serverURL": "https://monitor-mcp.contoso.com/mcp", + "tools": [ + { + "name": "query_logs", + "outputParsing": {"kind": "auto"}, + "inclusionMode": "reranked", + "maxOutputTokens": 4096, + } + ], + } + source.update(overrides) + return source + + def _query_headers(self): + return [ + { + "name": "Authorization", + "valueFrom": { + "kind": "managedIdentity", + "scope": "api://monitor/.default", + }, + }, + { + "name": "x-user-token", + "valueFrom": { + "kind": "obo", + "scope": "api://monitor/user_impersonation", + }, + }, + { + "name": "x-api-key", + "valueFrom": { + "kind": "keyVaultSecret", + "secretName": "monitor-api-key", + }, + }, + {"name": "x-no-auth", "valueFrom": {"kind": "none"}}, + ] + + def test_valid_query_header_metadata_is_preserved_in_canonical_json(self): + source = self._source(queryHeaders=self._query_headers()) + context = self._context(sources=[source]) + + canonical = mcp_setup.validate_and_get_mcp_sources(context) + + self.assertEqual(canonical[0]["queryHeaders"], self._query_headers()) + serialized = json.dumps(canonical) + self.assertNotIn("literal-secret", serialized) + self.assertNotIn('"value":', serialized) + + def test_query_headers_reject_literal_and_nested_credentials_without_echoing_values(self): + cases = { + "literal value": { + "name": "Authorization", + "value": "literal-secret-marker", + "valueFrom": { + "kind": "managedIdentity", + "scope": "api://monitor/.default", + }, + }, + "nested token": { + "name": "Authorization", + "valueFrom": { + "kind": "managedIdentity", + "scope": "api://monitor/.default", + "access_token": "literal-secret-marker", + }, + }, + "headers blob": { + "name": "x-custom", + "valueFrom": { + "kind": "none", + "storedHeaders": {"x-api-key": "literal-secret-marker"}, + }, + }, + "connection string": { + "name": "x-custom", + "valueFrom": { + "kind": "none", + "connection-string": "literal-secret-marker", + }, + }, + } + for name, query_header in cases.items(): + with self.subTest(name=name): + context = self._context( + sources=[self._source(queryHeaders=[query_header])] + ) + with self.assertRaises(ValueError) as exc_info: + mcp_setup.validate_and_get_mcp_sources(context) + self.assertIn("literal credentials", str(exc_info.exception)) + self.assertNotIn("literal-secret-marker", str(exc_info.exception)) + + def test_query_headers_reject_forbidden_names_and_invalid_value_from_shapes(self): + cases = { + "invalid token": { + "name": "bad header", + "valueFrom": {"kind": "none"}, + }, + "host": { + "name": "Host", + "valueFrom": {"kind": "none"}, + }, + "content length": { + "name": "Content-Length", + "valueFrom": {"kind": "none"}, + }, + "hop by hop": { + "name": "Connection", + "valueFrom": {"kind": "none"}, + }, + "managed identity missing scope": { + "name": "Authorization", + "valueFrom": {"kind": "managedIdentity"}, + }, + "obo with secretName": { + "name": "Authorization", + "valueFrom": { + "kind": "obo", + "scope": "api://monitor/user_impersonation", + "secretName": "not-applicable", + }, + }, + "key vault with scope": { + "name": "x-api-key", + "valueFrom": { + "kind": "keyVaultSecret", + "scope": "api://not-applicable", + "secretName": "monitor-api-key", + }, + }, + "none with credential field": { + "name": "x-none", + "valueFrom": {"kind": "none", "scope": "api://not-applicable"}, + }, + "unknown nested field": { + "name": "x-custom", + "valueFrom": {"kind": "none", "issuer": "contoso"}, + }, + } + for name, query_header in cases.items(): + with self.subTest(name=name): + context = self._context( + sources=[self._source(queryHeaders=[query_header])] + ) + with self.assertRaises(ValueError): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_duplicate_query_header_names_are_rejected_case_insensitively(self): + headers = [ + {"name": "X-Custom", "valueFrom": {"kind": "none"}}, + {"name": "x-custom", "valueFrom": {"kind": "none"}}, + ] + context = self._context(sources=[self._source(queryHeaders=headers)]) + with self.assertRaisesRegex(ValueError, "unique"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_disabled_is_a_no_op(self): + context = self._context(sources=[], FOUNDRY_IQ_MCP_ENABLED="false") + mcp_setup.validate_foundry_iq_mcp_settings(context) # must not raise + + def test_enabled_with_no_sources_raises(self): + context = self._context(sources=[]) + with self.assertRaisesRegex(ValueError, "no sources"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_missing_planning_model_raises(self): + context = self._context(sources=[self._source()], GPT_MODEL_INFO={}) + with self.assertRaisesRegex(ValueError, "planning model"): + mcp_setup.validate_foundry_iq_mcp_settings(context) + + def test_missing_ai_services_endpoint_raises(self): + context = self._context(sources=[self._source()], FOUNDRY_IQ_AI_SERVICES_ENDPOINT="") + with self.assertRaisesRegex(ValueError, "AI Services endpoint"): + mcp_setup.validate_foundry_iq_mcp_settings(context) + + def test_invalid_sources_json_type_raises(self): + context = self._context(sources={"not": "a list"}) + with self.assertRaisesRegex(ValueError, "JSON array"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_duplicate_source_names_raise(self): + context = self._context( + sources=[self._source(), self._source(name="MONITOR-MCP-KS")] + ) + with self.assertRaisesRegex(ValueError, "used more than once"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_invalid_source_name_raises_before_search_api_use(self): + context = self._context(sources=[self._source(name="../indexes/victim")]) + with self.assertRaisesRegex(ValueError, "must start with a letter or number"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_non_https_scheme_raises(self): + context = self._context(sources=[self._source(serverURL="http://monitor-mcp.contoso.com/mcp")]) + with self.assertRaisesRegex(ValueError, "https"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_malformed_port_raises(self): + context = self._context( + sources=[self._source(serverURL="https://monitor-mcp.contoso.com:notaport/mcp")] + ) + with self.assertRaisesRegex(ValueError, "not a valid URL"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_userinfo_in_url_raises(self): + context = self._context( + sources=[self._source(serverURL="https://user:pass@monitor-mcp.contoso.com/mcp")] + ) + with self.assertRaisesRegex(ValueError, "userinfo"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_fragment_in_url_raises(self): + context = self._context(sources=[self._source(serverURL="https://monitor-mcp.contoso.com/mcp#frag")]) + with self.assertRaisesRegex(ValueError, "fragment"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_query_string_in_url_raises(self): + for server_url in ( + "https://monitor-mcp.contoso.com/mcp?api-version=1", + "https://monitor-mcp.contoso.com/mcp?", + ): + with self.subTest(server_url=server_url): + context = self._context(sources=[self._source(serverURL=server_url)]) + with self.assertRaisesRegex(ValueError, "query string"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_source_scan_precedes_planning_model_validation(self): + context = self._context( + sources=[self._source(auth={"kind": "managedIdentity"})], + GPT_MODEL_INFO={}, + ) + with self.assertRaisesRegex(ValueError, "not allowed"): + mcp_setup.validate_foundry_iq_mcp_settings(context) + + def test_disabled_stale_source_content_is_discarded_without_parsing(self): + context = self._context( + sources="{not valid JSON", + FOUNDRY_IQ_MCP_ENABLED="false", + ) + mcp_setup.validate_foundry_iq_mcp_settings(context) + self.assertEqual(context["FOUNDRY_IQ_MCP_SOURCES_JSON"], []) + + def test_ip_literal_host_raises(self): + context = self._context( + sources=[self._source(serverURL="https://203.0.113.10/mcp")], + FOUNDRY_IQ_MCP_TRUSTED_HOSTS="203.0.113.10", + ) + with self.assertRaisesRegex(ValueError, "IP literal"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_loopback_ip_literal_host_raises(self): + context = self._context( + sources=[self._source(serverURL="https://127.0.0.1/mcp")], + FOUNDRY_IQ_MCP_TRUSTED_HOSTS="127.0.0.1", + ) + with self.assertRaisesRegex(ValueError, "IP literal"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_localhost_host_raises(self): + context = self._context( + sources=[self._source(serverURL="https://localhost/mcp")], + FOUNDRY_IQ_MCP_TRUSTED_HOSTS="localhost", + ) + with self.assertRaisesRegex(ValueError, "local or reserved"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_reserved_or_single_label_host_raises(self): + for host in ( + "home.arpa", + "mcp.internal", + "mcp.example", + "mcp.corp", + "mcp.intranet", + "mcp.private", + "mcp.123", + "singlelabel", + ): + with self.subTest(host=host): + context = self._context( + sources=[self._source(serverURL=f"https://{host}/mcp")], + FOUNDRY_IQ_MCP_TRUSTED_HOSTS=host, + ) + with self.assertRaisesRegex(ValueError, "local or reserved"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_host_not_in_trusted_allowlist_raises(self): + context = self._context( + sources=[self._source()], + FOUNDRY_IQ_MCP_TRUSTED_HOSTS="other-host.contoso.com", + ) + with self.assertRaisesRegex(ValueError, "not an exact match"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_empty_trusted_hosts_raises(self): + context = self._context(sources=[self._source()], FOUNDRY_IQ_MCP_TRUSTED_HOSTS="") + with self.assertRaisesRegex(ValueError, "TRUSTED_HOSTS is empty"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_trusted_hosts_accept_runtime_json_array_shape(self): + context = self._context( + sources=[self._source()], + FOUNDRY_IQ_MCP_TRUSTED_HOSTS='["monitor-mcp.contoso.com"]', + ) + self.assertEqual( + mcp_setup.validate_and_get_mcp_sources(context)[0]["serverURL"], + "https://monitor-mcp.contoso.com/mcp", + ) + + def test_trusted_hosts_reject_non_hostname_entries(self): + for trusted_hosts in ( + "https://monitor-mcp.contoso.com", + "monitor-mcp.contoso.com/mcp", + "monitor-mcp.contoso.com:443", + ): + with self.subTest(trusted_hosts=trusted_hosts): + context = self._context( + sources=[self._source()], + FOUNDRY_IQ_MCP_TRUSTED_HOSTS=trusted_hosts, + ) + with self.assertRaisesRegex(ValueError, "hostnames only"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_no_tools_raises(self): + context = self._context(sources=[self._source(tools=[])]) + with self.assertRaisesRegex(ValueError, "non-empty array"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_duplicate_tool_names_raise(self): + tool = { + "name": "query_logs", + "outputParsing": {"kind": "auto"}, + "inclusionMode": "reranked", + "maxOutputTokens": 100, + } + context = self._context( + sources=[ + self._source( + tools=[tool, {**tool, "name": tool["name"].upper()}] + ) + ] + ) + with self.assertRaisesRegex(ValueError, "used more than once"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_invalid_inclusion_mode_raises(self): + context = self._context( + sources=[self._source(tools=[{**self._source()["tools"][0], "inclusionMode": "sometimes"}])] + ) + with self.assertRaisesRegex(ValueError, "inclusionMode"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_invalid_output_parsing_raises(self): + context = self._context( + sources=[ + self._source( + tools=[ + { + **self._source()["tools"][0], + "outputParsing": {"kind": "yaml"}, + } + ] + ) + ] + ) + with self.assertRaisesRegex(ValueError, "outputParsing"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_legacy_string_output_parsing_is_rejected(self): + context = self._context( + sources=[ + self._source( + tools=[ + { + **self._source()["tools"][0], + "outputParsing": "auto", + } + ] + ) + ] + ) + with self.assertRaisesRegex(ValueError, "JSON object"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_json_output_parsing_without_documents_path_raises(self): + context = self._context( + sources=[ + self._source( + tools=[ + { + **self._source()["tools"][0], + "outputParsing": {"kind": "json"}, + } + ] + ) + ] + ) + with self.assertRaisesRegex(ValueError, "jsonParameters"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_documents_path_with_non_json_output_parsing_raises(self): + """documentsPath only has an effect (and is only rendered) nested + under outputParsing.jsonParameters when outputParsing is 'json'; + silently accepting it for other kinds would mislead operators.""" + context = self._context( + sources=[ + self._source( + tools=[ + { + **self._source()["tools"][0], + "outputParsing": { + "kind": "auto", + "jsonParameters": {"documentsPath": "$.results"}, + }, + } + ] + ) + ] + ) + with self.assertRaisesRegex(ValueError, "jsonParameters"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_invalid_documented_output_parsing_parameters_raise(self): + cases = [ + ( + "includeContext must be boolean", + { + "kind": "json", + "jsonParameters": { + "documentsPath": "$.results", + "includeContext": "true", + }, + }, + "includeContext", + ), + ( + "JSON parameter keys are strict", + { + "kind": "json", + "jsonParameters": { + "documentsPath": "$.results", + "unexpected": True, + }, + }, + "unexpected key", + ), + ( + "split parameters require split kind", + { + "kind": "auto", + "splitParameters": {"textSplitMode": "pages"}, + }, + "splitParameters", + ), + ( + "split parameter keys are strict", + { + "kind": "split", + "splitParameters": {"unit": "azureOpenAITokens"}, + }, + "unexpected key", + ), + ( + "split mode is constrained", + { + "kind": "split", + "splitParameters": {"textSplitMode": "paragraphs"}, + }, + "textSplitMode", + ), + ( + "split lengths must be integers", + { + "kind": "split", + "splitParameters": {"maximumPageLength": True}, + }, + "positive integer", + ), + ( + "split overlap must be smaller than page length", + { + "kind": "split", + "splitParameters": { + "maximumPageLength": 100, + "pageOverlapLength": 100, + }, + }, + "less than maximumPageLength", + ), + ] + + for label, output_parsing, expected_error in cases: + with self.subTest(label): + context = self._context( + sources=[ + self._source( + tools=[ + { + **self._source()["tools"][0], + "outputParsing": output_parsing, + } + ] + ) + ] + ) + with self.assertRaisesRegex(ValueError, expected_error): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_non_positive_max_output_tokens_raises(self): + context = self._context( + sources=[self._source(tools=[{**self._source()["tools"][0], "maxOutputTokens": 0}])] + ) + with self.assertRaisesRegex(ValueError, "positive integer"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_max_output_tokens_above_local_cap_raises(self): + context = self._context( + sources=[self._source(tools=[{**self._source()["tools"][0], "maxOutputTokens": 20000}])] + ) + with self.assertRaisesRegex(ValueError, "local cap"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_always_query_source_on_tool_raises(self): + context = self._context( + sources=[self._source(tools=[{**self._source()["tools"][0], "alwaysQuerySource": True}])] + ) + with self.assertRaisesRegex(ValueError, "alwaysQuerySource"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_always_query_source_on_source_raises(self): + context = self._context(sources=[self._source(alwaysQuerySource=True)]) + with self.assertRaisesRegex(ValueError, "alwaysQuerySource"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_foundry_connection_auth_raises(self): + context = self._context(sources=[self._source(auth={"kind": "foundryConnection"})]) + with self.assertRaisesRegex(ValueError, "not allowed"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_secret_like_auth_key_raises(self): + context = self._context(sources=[self._source(apiKey="not-a-real-secret")]) + with self.assertRaisesRegex(ValueError, "literal credentials"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_unsupported_auth_kind_raises(self): + context = self._context(sources=[self._source(auth={"kind": "oauth2"})]) + with self.assertRaisesRegex(ValueError, "not allowed"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_managed_identity_auth_is_rejected(self): + """auth is rejected outright, at any nesting depth, regardless of + kind -- it is never rendered/forwarded, so even a would-be-safe + {'kind': 'managedIdentity'} value must not be silently accepted.""" + context = self._context(sources=[self._source(auth={"kind": "managedIdentity"})]) + with self.assertRaisesRegex(ValueError, "not allowed"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_authentication_key_is_rejected(self): + context = self._context(sources=[self._source(authentication={"kind": "foundryConnection"})]) + with self.assertRaisesRegex(ValueError, "not allowed"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_auth_nested_inside_tool_is_rejected(self): + tool = {**self._source()["tools"][0], "auth": {"kind": "managedIdentity"}} + context = self._context(sources=[self._source(tools=[tool])]) + with self.assertRaisesRegex(ValueError, "not allowed"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_secret_like_key_nested_inside_tool_is_rejected(self): + tool = {**self._source()["tools"][0], "token": "not-a-real-token"} + context = self._context(sources=[self._source(tools=[tool])]) + with self.assertRaisesRegex(ValueError, "literal credentials"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_secret_like_key_two_levels_deep_is_rejected(self): + """The scan must recurse arbitrarily deep, not just one level.""" + tool = {**self._source()["tools"][0], "extra": {"nested": {"bearer": "not-a-real-token"}}} + context = self._context(sources=[self._source(tools=[tool])]) + with self.assertRaisesRegex(ValueError, "literal credentials"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_absent_auth_is_valid(self): + context = self._context(sources=[self._source()]) + sources = mcp_setup.validate_and_get_mcp_sources(context) + self.assertEqual(len(sources), 1) + + def test_unexpected_source_key_raises(self): + context = self._context(sources=[self._source(unexpectedKey="value")]) + with self.assertRaisesRegex(ValueError, "unexpected key"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_runtime_retrieval_controls_are_validated_and_preserved(self): + context = self._context( + sources=[ + self._source( + failOnError=False, + maxOutputDocuments=25, + ) + ] + ) + + sources = mcp_setup.validate_and_get_mcp_sources(context) + + self.assertFalse(sources[0]["failOnError"]) + self.assertEqual(sources[0]["maxOutputDocuments"], 25) + + def test_invalid_runtime_retrieval_controls_raise(self): + cases = [ + ({"failOnError": "false"}, "failOnError"), + ({"failOnError": None}, "failOnError"), + ({"maxOutputDocuments": True}, "maxOutputDocuments"), + ({"maxOutputDocuments": 0}, "maxOutputDocuments"), + ({"maxOutputDocuments": 51}, "maxOutputDocuments"), + ] + for overrides, expected_error in cases: + with self.subTest(overrides=overrides): + context = self._context(sources=[self._source(**overrides)]) + with self.assertRaisesRegex(ValueError, expected_error): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_legacy_server_url_casing_is_rejected(self): + source = self._source() + source["serverUrl"] = source.pop("serverURL") + context = self._context(sources=[source]) + with self.assertRaisesRegex(ValueError, "unexpected key"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_invalid_reasoning_effort_raises(self): + context = self._context(sources=[self._source()], FOUNDRY_IQ_MCP_REASONING_EFFORT="minimal") + with self.assertRaisesRegex(ValueError, "REASONING_EFFORT"): + mcp_setup.validate_and_get_mcp_sources(context) + + +class FoundryIqMcpPreflightCliTests(unittest.TestCase): + """Unit tests for the standalone pre-flight CLI entry point + (mcp_setup.main / build_preflight_context_from_environ). This is the + module scripts/postProvision.ps1 runs before it imports anything into + Azure App Configuration, so a rejected configuration here proves the + ordering bug (import happening before validation) cannot recur: a + non-zero exit here happens before any write.""" + + BASE_ENV = { + "RETRIEVAL_BACKEND": "foundry_iq", + "FOUNDRY_IQ_MCP_ENABLED": "true", + "FOUNDRY_IQ_MCP_TRUSTED_HOSTS": "monitor-mcp.contoso.com", + "FOUNDRY_IQ_MCP_REASONING_EFFORT": "low", + } + + def _sources_json(self, **overrides): + source = { + "name": "monitor-mcp-ks", + "serverURL": "https://monitor-mcp.contoso.com/mcp", + "tools": [ + { + "name": "query_logs", + "outputParsing": {"kind": "auto"}, + "inclusionMode": "reranked", + "maxOutputTokens": 4096, + } + ], + } + source.update(overrides) + return json.dumps([source]) + + def _run_main(self, **env_overrides): + env = {**self.BASE_ENV, **env_overrides} + with patch.dict(os.environ, env, clear=False): + stderr = io.StringIO() + with redirect_stderr(stderr): + result = mcp_setup.main() + return result, stderr.getvalue() + + def test_disabled_by_default_returns_zero_without_error(self): + result, stderr = self._run_main(FOUNDRY_IQ_MCP_ENABLED="false", FOUNDRY_IQ_MCP_SOURCES_JSON="[]") + self.assertEqual(result, 0) + self.assertEqual(stderr, "") + + def test_disabled_malformed_json_returns_zero_without_parsing(self): + result, stderr = self._run_main( + FOUNDRY_IQ_MCP_ENABLED="false", + FOUNDRY_IQ_MCP_SOURCES_JSON="{not valid JSON", + ) + self.assertEqual(result, 0) + self.assertEqual(stderr, "") + + def test_disabled_credential_json_returns_zero_without_scanning(self): + result, stderr = self._run_main( + FOUNDRY_IQ_MCP_ENABLED="false", + FOUNDRY_IQ_MCP_SOURCES_JSON='[{"auth":{"kind":"foundryConnection","token":"stale"}}]', + ) + self.assertEqual(result, 0) + self.assertEqual(stderr, "") + + def test_valid_configuration_returns_zero(self): + result, stderr = self._run_main(FOUNDRY_IQ_MCP_SOURCES_JSON=self._sources_json()) + self.assertEqual(result, 0) + self.assertEqual(stderr, "") + + def test_valid_configuration_emits_canonical_json_for_app_configuration(self): + query_headers = [ + { + "name": " Authorization ", + "valueFrom": { + "kind": "managedIdentity", + "scope": " api://monitor/.default ", + }, + }, + { + "name": "x-api-key", + "valueFrom": { + "kind": "keyVaultSecret", + "secretName": "monitor-api-key", + }, + }, + ] + env = { + **self.BASE_ENV, + "FOUNDRY_IQ_MCP_SOURCES_JSON": self._sources_json( + failOnError=False, + maxOutputDocuments=25, + queryHeaders=query_headers, + ), + } + stdout = io.StringIO() + stderr = io.StringIO() + with patch.dict(os.environ, env, clear=False): + with redirect_stdout(stdout), redirect_stderr(stderr): + result = mcp_setup.main(emit_canonical=True) + + self.assertEqual(result, 0) + self.assertEqual(stderr.getvalue(), "") + canonical = json.loads(stdout.getvalue()) + self.assertFalse(canonical[0]["failOnError"]) + self.assertEqual(canonical[0]["maxOutputDocuments"], 25) + self.assertEqual( + canonical[0]["queryHeaders"], + [ + { + "name": "Authorization", + "valueFrom": { + "kind": "managedIdentity", + "scope": "api://monitor/.default", + }, + }, + { + "name": "x-api-key", + "valueFrom": { + "kind": "keyVaultSecret", + "secretName": "monitor-api-key", + }, + }, + ], + ) + + def test_malformed_json_returns_one_before_any_write(self): + result, stderr = self._run_main(FOUNDRY_IQ_MCP_SOURCES_JSON="{not valid json") + self.assertEqual(result, 1) + self.assertIn("valid JSON", stderr) + + def test_auth_key_returns_one_before_any_write(self): + """The exact regression scenario for the postProvision.ps1 ordering + fix: an operator-supplied auth key must fail this gate (exit 1) + before scripts/postProvision.ps1 ever imports + FOUNDRY_IQ_MCP_SOURCES_JSON into App Configuration.""" + result, stderr = self._run_main( + FOUNDRY_IQ_MCP_SOURCES_JSON=self._sources_json(auth={"kind": "managedIdentity"}) + ) + self.assertEqual(result, 1) + self.assertIn("not allowed", stderr) + + def test_secret_like_key_returns_one_before_any_write(self): + result, stderr = self._run_main(FOUNDRY_IQ_MCP_SOURCES_JSON=self._sources_json(apiKey="not-a-real-secret")) + self.assertEqual(result, 1) + self.assertIn("literal credentials", stderr) + + def test_untrusted_host_returns_one_before_any_write(self): + result, stderr = self._run_main( + FOUNDRY_IQ_MCP_SOURCES_JSON=self._sources_json(), + FOUNDRY_IQ_MCP_TRUSTED_HOSTS="other-host.contoso.com", + ) + self.assertEqual(result, 1) + self.assertIn("not an exact match", stderr) + + def test_module_has_no_azure_sdk_dependency(self): + """Regression guard: this module must never itself write to Azure + App Configuration (or import an SDK capable of it), so it can be run + as a pure pre-flight gate with only the system Python interpreter, + before scripts/postProvision.ps1 installs any pip dependency.""" + source = inspect.getsource(mcp_setup) + self.assertNotIn("azure.appconfiguration", source) + self.assertNotIn("AzureAppConfigurationClient", source) + self.assertNotIn("import azure", source) + + +class PostProvisionMcpSourceGuardTests(unittest.TestCase): + """Regression guards for the PowerShell validation/write ordering.""" + + SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "postProvision.ps1" + + def test_disabled_path_skips_source_read_and_writes_safe_defaults(self): + script = self.SCRIPT.read_text(encoding="utf-8") + flag = script.index("$mcpEnabled = Test-Truthy") + preflight = script.index( + "Invoke-PythonModule -ModuleName 'config.search.foundry_iq_mcp_setup'" + ) + canonical_output = script.index( + "-Arguments @('--canonical')" + ) + app_config_import = script.index("Set-GptRagAppConfiguration -Endpoint") + + self.assertLess(flag, preflight) + self.assertLess(preflight, canonical_output) + self.assertLess(canonical_output, app_config_import) + self.assertNotIn( + "$mcpSourcesJson = Get-OptionalEnvValue 'FOUNDRY_IQ_MCP_SOURCES_JSON'", + script, + ) + self.assertIn("$mcpSourcesJson = '[]'", script) + self.assertIn("$mcpReasoningEffort = 'low'", script) + self.assertIn("$mcpTrustedHosts = ''", script) + self.assertIn("$mcpLogToolArguments = 'false'", script) + + +class WorkIqPreflightTests(unittest.TestCase): + def test_filter_work_iq_sources_no_op_when_disabled(self): + defs = { + "knowledgeSources": [ + {"name": "blob-ks", "kind": "azureBlob"}, + ], + "knowledgeBases": [ + {"name": "kb", "knowledgeSources": [{"name": "blob-ks"}]}, + ], + } + setup.filter_work_iq_sources( + defs, + {"RETRIEVAL_BACKEND": "foundry_iq", "WORK_IQ_ENABLED": "false"}, + Mock(), + ) + self.assertEqual(defs["knowledgeSources"], [{"name": "blob-ks", "kind": "azureBlob"}]) + self.assertEqual(defs["knowledgeBases"][0]["knowledgeSources"], [{"name": "blob-ks"}]) + + def test_filter_work_iq_sources_removes_source_when_not_consented(self): + defs = { + "knowledgeSources": [ + {"name": "blob-ks", "kind": "azureBlob"}, + {"name": "work-iq-ks", "kind": "workIQ"}, + ], + "knowledgeBases": [ + { + "name": "kb", + "knowledgeSources": [{"name": "blob-ks"}, {"name": "work-iq-ks"}], + } + ], + } + credential = Mock() + with patch.object(setup, "check_work_iq_admin_consent", return_value=False): + setup.filter_work_iq_sources( + defs, + { + "RETRIEVAL_BACKEND": "foundry_iq", + "WORK_IQ_ENABLED": "true", + "WORK_IQ_KNOWLEDGE_SOURCE_NAME": "work-iq-ks", + }, + credential, + ) + self.assertEqual([ks["name"] for ks in defs["knowledgeSources"]], ["blob-ks"]) + self.assertEqual( + [ref["name"] for ref in defs["knowledgeBases"][0]["knowledgeSources"]], + ["blob-ks"], + ) + + def test_filter_work_iq_sources_keeps_source_when_consented(self): + defs = { + "knowledgeSources": [ + {"name": "work-iq-ks", "kind": "workIQ"}, + ], + "knowledgeBases": [ + {"name": "kb", "knowledgeSources": [{"name": "work-iq-ks"}]} + ], + } + with patch.object(setup, "check_work_iq_admin_consent", return_value=True): + setup.filter_work_iq_sources( + defs, + { + "RETRIEVAL_BACKEND": "foundry_iq", + "WORK_IQ_ENABLED": "true", + "WORK_IQ_KNOWLEDGE_SOURCE_NAME": "work-iq-ks", + }, + Mock(), + ) + self.assertEqual([ks["name"] for ks in defs["knowledgeSources"]], ["work-iq-ks"]) + + def test_filter_work_iq_sources_removes_source_when_preflight_inconclusive(self): + defs = { + "knowledgeSources": [{"name": "work-iq-ks", "kind": "workIQ"}], + "knowledgeBases": [ + {"name": "kb", "knowledgeSources": [{"name": "work-iq-ks"}]} + ], + } + with patch.object(setup, "check_work_iq_admin_consent", return_value=None): + setup.filter_work_iq_sources( + defs, + { + "RETRIEVAL_BACKEND": "foundry_iq", + "WORK_IQ_ENABLED": "true", + "WORK_IQ_KNOWLEDGE_SOURCE_NAME": "work-iq-ks", + }, + Mock(), + ) + self.assertEqual(defs["knowledgeSources"], []) + self.assertEqual(defs["knowledgeBases"][0]["knowledgeSources"], []) + + +class KnowledgeSourceNameUniquenessTests(unittest.TestCase): + """Comprehensive collision coverage for + setup.validate_unique_knowledge_source_names across every knowledge + source kind search.j2 can render: Blob/Search Index, conversation + Search Index, Work IQ, Fabric IQ, Fabric Data Agent, SharePoint Indexed, + Web grounding, and MCP Server.""" + + ALL_CATEGORIES = ( + "blob", + "conversation", + "work_iq", + "fabric_iq", + "fabric_data_agent", + "sharepoint_indexed", + "web_grounding", + "mcp", + ) + + def _default_names(self, **overrides): + names = {category: f"{category}-ks" for category in self.ALL_CATEGORIES} + names.update(overrides) + return names + + def _defs_with_all_categories_enabled(self, **name_overrides): + names = self._default_names(**name_overrides) + settings_input = { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + "FOUNDRY_IQ_KNOWLEDGE_SOURCE_NAME": names["blob"], + "FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED": "true", + "FOUNDRY_IQ_CONVERSATION_KNOWLEDGE_SOURCE_NAME": names["conversation"], + "WORK_IQ_ENABLED": "true", + "WORK_IQ_KNOWLEDGE_SOURCE_NAME": names["work_iq"], + "FABRIC_IQ_ENABLED": "true", + "FABRIC_IQ_KNOWLEDGE_SOURCE_NAME": names["fabric_iq"], + "FABRIC_IQ_WORKSPACE_ID": "workspace-1", + "FABRIC_IQ_ONTOLOGY_ID": "ontology-1", + "FABRIC_DATA_AGENT_ENABLED": "true", + "FABRIC_DATA_AGENT_KNOWLEDGE_SOURCE_NAME": names["fabric_data_agent"], + "FABRIC_DATA_AGENT_WORKSPACE_ID": "workspace-1", + "FABRIC_DATA_AGENT_DATA_AGENT_ID": "agent-1", + "SHAREPOINT_INDEXED_ENABLED": "true", + "SHAREPOINT_INDEXED_KNOWLEDGE_SOURCE_NAME": names["sharepoint_indexed"], + "SHAREPOINT_INDEXED_INDEX_NAME": "sp-index", + "SHAREPOINT_INDEXED_SITE_URL": "https://contoso.sharepoint.com/sites/eng", + "SHAREPOINT_INDEXED_TENANT_ID": "tenant-guid", + "WEB_GROUNDING_ENABLED": "true", + "WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME": names["web_grounding"], + "FOUNDRY_IQ_MCP_ENABLED": "true", + } + settings = render_json_template("search.settings.j2", settings_input) + context = { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": {"deployment_name": "chat", "model_name": "gpt-5-nano"}, + "FOUNDRY_IQ_MCP_SOURCES_JSON": [ + { + "name": names["mcp"], + "serverURL": "https://mcp.contoso.com/mcp", + "tools": [ + { + "name": "query", + "outputParsing": {"kind": "auto"}, + "inclusionMode": "reranked", + "maxOutputTokens": 1024, + } + ], + } + ], + } + return render_json_template("search.j2", context) + + def test_all_categories_enabled_with_distinct_names_pass(self): + defs = self._defs_with_all_categories_enabled() + names = [ks["name"] for ks in defs["knowledgeSources"]] + self.assertEqual(len(names), len(self.ALL_CATEGORIES)) + self.assertEqual(len(names), len(set(n.lower() for n in names))) + setup.validate_unique_knowledge_source_names(defs) # must not raise + + def test_exact_case_collision_across_every_category_pair_raises(self): + for category_a, category_b in itertools.combinations(self.ALL_CATEGORIES, 2): + with self.subTest(colliding=(category_a, category_b)): + defs = self._defs_with_all_categories_enabled(**{category_b: f"{category_a}-ks"}) + with self.assertRaisesRegex(ValueError, "[Cc]ollides"): + setup.validate_unique_knowledge_source_names(defs) + + def test_case_insensitive_collision_raises(self): + defs = self._defs_with_all_categories_enabled(mcp="BLOB-KS") + with self.assertRaisesRegex(ValueError, "case-insensitive"): + setup.validate_unique_knowledge_source_names(defs) + + def test_no_knowledge_sources_passes(self): + setup.validate_unique_knowledge_source_names({"knowledgeSources": []}) # must not raise + setup.validate_unique_knowledge_source_names({}) # must not raise + + def test_single_knowledge_source_passes(self): + setup.validate_unique_knowledge_source_names({"knowledgeSources": [{"name": "blob-ks"}]}) + + def test_two_distinct_names_pass(self): + setup.validate_unique_knowledge_source_names( + {"knowledgeSources": [{"name": "blob-ks"}, {"name": "mcp-ks"}]} + ) + + def test_duplicate_same_case_raises(self): + with self.assertRaisesRegex(ValueError, "collides"): + setup.validate_unique_knowledge_source_names( + {"knowledgeSources": [{"name": "blob-ks"}, {"name": "blob-ks"}]} + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/manifest.json b/manifest.json index 27c753d0..d0b0a295 100644 --- a/manifest.json +++ b/manifest.json @@ -11,7 +11,7 @@ { "name": "gpt-rag-orchestrator", "repo": "https://github.com/azure/gpt-rag-orchestrator.git", - "tag": "v3.5.0" + "tag": "v3.7.0" }, { "name": "gpt-rag-ingestion", diff --git a/scripts/postProvision.ps1 b/scripts/postProvision.ps1 index 4ebdb7f7..cf512bde 100755 --- a/scripts/postProvision.ps1 +++ b/scripts/postProvision.ps1 @@ -145,6 +145,58 @@ function ConvertTo-FlatJsonString { return ($Value | ConvertTo-Json -Depth 50 -Compress) } +#------------------------------------------------------------------------------- +# Make config.* importable early (moved ahead of Set-GptRagAppConfiguration so +# the Foundry IQ MCP pre-flight validation below can run before any App +# Configuration write/import). No Python package installation is required for +# that pre-flight check: foundry_iq_mcp_setup.py has no external dependencies. +#------------------------------------------------------------------------------- +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$env:PYTHONPATH = if ($env:PYTHONPATH) { "$repoRoot;$($env:PYTHONPATH)" } else { $repoRoot } +$env:GPT_RAG_REPO_ROOT = $repoRoot +Set-Location $repoRoot + +function Invoke-PythonModule { + param( + [Parameter(Mandatory = $true)][string]$ModuleName, + [string[]]$Arguments = @() + ) + Invoke-NativeCommand { + & python -c "import os, runpy, sys; sys.path.insert(0, os.environ['GPT_RAG_REPO_ROOT']); sys.argv = ['$ModuleName'] + sys.argv[1:]; runpy.run_module('$ModuleName', run_name='__main__')" @Arguments + } +} + +#------------------------------------------------------------------------------- +# Foundry IQ MCP Server source pre-flight validation +# Validates FOUNDRY_IQ_MCP_SOURCES_JSON (JSON shape, security constraints: +# only non-secret queryHeaders references, no auth/authentication or literal +# credential material at any nesting depth, trusted-host allowlisting, +# output-parsing shape, token caps) before ANY of it is imported into Azure +# App Configuration below. The validator returns canonical JSON so the raw +# operator value is never persisted. +#------------------------------------------------------------------------------- +$mcpEnabled = Test-Truthy (Get-OptionalEnvValue 'FOUNDRY_IQ_MCP_ENABLED' 'false') +if ($mcpEnabled) { + Write-Host "🔐 Validating Foundry IQ MCP Server source configuration..." + $mcpSourcesJson = Invoke-PythonModule -ModuleName 'config.search.foundry_iq_mcp_setup' -Arguments @('--canonical') + if ($LASTEXITCODE -ne 0) { + Write-Error "Foundry IQ MCP Server source validation failed; aborting before any App Configuration write." + exit 1 + } + $mcpSourcesJson = ([string]$mcpSourcesJson).Trim() + $mcpReasoningEffort = Get-OptionalEnvValue 'FOUNDRY_IQ_MCP_REASONING_EFFORT' 'low' + $mcpTrustedHosts = Get-OptionalEnvValue 'FOUNDRY_IQ_MCP_TRUSTED_HOSTS' '' + $mcpLogToolArguments = Get-OptionalEnvValue 'FOUNDRY_IQ_MCP_LOG_TOOL_ARGUMENTS' 'false' +} else { + # Disabled provisioning must not parse, preserve, or persist stale source + # content. Write canonical safe defaults instead. + Write-Host "⏭️ Foundry IQ MCP Server disabled; skipping source validation." + $mcpSourcesJson = '[]' + $mcpReasoningEffort = 'low' + $mcpTrustedHosts = '' + $mcpLogToolArguments = 'false' +} + function Set-GptRagAppConfiguration { param( [Parameter(Mandatory = $true)][string]$Endpoint, @@ -430,6 +482,23 @@ function Set-GptRagAppConfiguration { WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME = (Get-OptionalEnvValue 'WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME' '') WEB_GROUNDING_ALLOWED_DOMAINS = (Get-OptionalEnvValue 'WEB_GROUNDING_ALLOWED_DOMAINS' '') WEB_GROUNDING_BLOCKED_DOMAINS = (Get-OptionalEnvValue 'WEB_GROUNDING_BLOCKED_DOMAINS' '') + # Generic MCP Server knowledge source passthrough keys (preview). + # Opt-in via FOUNDRY_IQ_MCP_ENABLED=true plus at least one source in + # FOUNDRY_IQ_MCP_SOURCES_JSON (a JSON array; see + # docs/howto_grounding_mcp_server.md for the schema). Disabled by + # default. No secrets belong in FOUNDRY_IQ_MCP_SOURCES_JSON or in + # App Configuration. queryHeaders may contain only managed identity + # or OBO scopes, Key Vault secret names, or explicit none metadata; + # the compatible orchestrator resolves values at request time. + # FOUNDRY_IQ_MCP_TRUSTED_HOSTS is a + # comma-separated allowlist of exact hostnames the provisioning + # script requires an MCP serverURL to match before it will + # register the source. + FOUNDRY_IQ_MCP_ENABLED = if ($mcpEnabled) { 'true' } else { 'false' } + FOUNDRY_IQ_MCP_SOURCES_JSON = $mcpSourcesJson + FOUNDRY_IQ_MCP_REASONING_EFFORT = $mcpReasoningEffort + FOUNDRY_IQ_MCP_TRUSTED_HOSTS = $mcpTrustedHosts + FOUNDRY_IQ_MCP_LOG_TOOL_ARGUMENTS = $mcpLogToolArguments NETWORK_ISOLATION = (Get-OptionalEnvValue 'NETWORK_ISOLATION' 'false') USE_UAI = (Get-OptionalEnvValue 'USE_UAI' 'false') USE_CAPP_API_KEY = (Get-OptionalEnvValue 'USE_CAPP_API_KEY' 'false') @@ -552,16 +621,6 @@ Set-GptRagAppConfiguration -Endpoint (Get-RequiredEnvValue 'APP_CONFIG_ENDPOINT' #------------------------------------------------------------------------------- # Setup Python environment #------------------------------------------------------------------------------- -$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -$env:PYTHONPATH = if ($env:PYTHONPATH) { "$repoRoot;$($env:PYTHONPATH)" } else { $repoRoot } -$env:GPT_RAG_REPO_ROOT = $repoRoot -Set-Location $repoRoot - -function Invoke-PythonModule { - param([Parameter(Mandatory = $true)][string]$ModuleName) - Invoke-NativeCommand { & python -c "import os, runpy, sys; sys.path.insert(0, os.environ['GPT_RAG_REPO_ROOT']); runpy.run_module('$ModuleName', run_name='__main__')" } -} - Write-Host "🐍 Checking Python venv support..." Invoke-NativeCommand { & python -c "import venv" 2>$null } $venvSupported = ($LASTEXITCODE -eq 0)