diff --git a/CHANGELOG.md b/CHANGELOG.md index f84cf2be..42621293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,43 @@ # Changelog -## [Unreleased] +## [v3.6.0] - 2026-07-19 + +### User and operator impact + +Adds opt-in provisioning for generic, approved, preconfigured MCP Server knowledge sources, resolving [#567](https://github.com/Azure/GPT-RAG/issues/567) through [GPT-RAG PR #568](https://github.com/Azure/GPT-RAG/pull/568), [docs PR #569](https://github.com/Azure/GPT-RAG/pull/569), and [orchestrator PR #275](https://github.com/Azure/gpt-rag-orchestrator/pull/275). The feature is **Preview and disabled by default**, so existing deployments do not change unless an operator explicitly enables it. + +Provisioning from GPT-RAG `v3.6.0` and [orchestrator `v3.7.0`](https://github.com/Azure/gpt-rag-orchestrator/releases/tag/v3.7.0) must be deployed together before enabling MCP. The configuration governs trusted HTTPS endpoints, exact tool allowlists, output parsing, per-source failure behavior, reasoning effort, runtime/output limits, and request-time authentication metadata. ### Added +- **Generic MCP Server knowledge source template.** Foundry IQ can call allowed tools on one or more operator-approved MCP servers and combine those results with existing knowledge sources. The Search integration uses API `2026-05-01-preview`; Azure Monitor MCP over workspace-based Application Insights is a worked example, not a special product code path. +- **Fail-closed source validation and canonical configuration.** Provisioning rejects malformed or empty source definitions, duplicate names, untrusted or unsafe endpoints, unsupported tool/output controls, forbidden headers, literal credentials, and invalid runtime limits before any App Configuration or Search write. Disabled configuration remains canonical and byte-identical to the prior rendered behavior. +- **Secure request-time authentication metadata.** Managed identity, OBO, Key Vault secret-name references, and no-auth mode are supported without rendering credential values into Search resources. Operators must apply least privilege and keep secrets out of source JSON. +- **Operator controls and rollback.** `FOUNDRY_IQ_MCP_ENABLED=false` remains the default and rollback gate. Trusted-host and tool allowlists, bounded reasoning/runtime/output, explicit parsing, and per-source failure behavior constrain the preview integration. + ### Changed -### Fixed +- **`manifest.json` now identifies umbrella release `v3.6.0` and pins orchestrator `v3.7.0`.** UI `v2.3.13`, ingestion `v2.4.14`, and infra `v2.3.0` are unchanged. + +### Security and operational guidance + +MCP tool safety does not guarantee that model-generated arguments are semantically correct. Use only trusted endpoints, exact read-only tool allowlists, bounded queries, least-privilege identities, and auditable server-side enforcement. The Azure Monitor example must retain workspace scope, bounded time ranges and row counts, and generated-KQL review. Production enablement requires a canary against the operator's approved endpoint and credentials. + +To roll back, set `FOUNDRY_IQ_MCP_ENABLED=false` and rerun post-provisioning or provisioning. This removes MCP references from the knowledge base and restores disabled planning behavior; an unused top-level Search knowledge-source resource may be deleted separately after confirming that no knowledge base references it. + +### Validation + +| Component | Version | +| --- | --- | +| gpt-rag-ui | v2.3.13 | +| gpt-rag-orchestrator | v3.7.0 | +| gpt-rag-ingestion | v2.4.14 | +| infra / AI Landing Zone | v2.3.0 | + +- GPT-RAG configuration and template suites: **109 tests passed, 66 subtests passed**. +- Orchestrator `v3.7.0`: **457 tests passed, 65 subtests passed**. +- Python compilation, PowerShell AST/preflight, JSON/manifest/remote-tag checks, canonical fixture compatibility, disabled-byte identity, Bicep build/size gate, strict documentation build, and Pages publication passed. +- No live Azure MCP end-to-end test was run because no approved MCP endpoint or credentials were available. The release is acceptable because MCP remains Preview, disabled by default, fail-closed, fully regression-tested, and reversible; production enablement still requires an operator-owned canary. ## [v3.5.1] - 2026-07-15 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 54a66a4e..f908592b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,5 @@ { - "tag": "v3.5.1", + "tag": "v3.6.0", "repo": "https://github.com/azure/gpt-rag.git", "ailz_tag": "v2.3.0", "components": [ @@ -11,7 +11,7 @@ { "name": "gpt-rag-orchestrator", "repo": "https://github.com/azure/gpt-rag-orchestrator.git", - "tag": "v3.5.1" + "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)