diff --git a/application/single_app/functions_global_actions.py b/application/single_app/functions_global_actions.py index 9d3956100..33c2cc831 100644 --- a/application/single_app/functions_global_actions.py +++ b/application/single_app/functions_global_actions.py @@ -31,6 +31,7 @@ validate_scoped_mcp_action, ) from functions_settings import get_settings +from functions_mcp_tool_pinning import validate_mcp_tool_pinning_for_save def _clean_action(action, return_type, action_id=None): @@ -165,6 +166,7 @@ def save_global_action(action_data, user_id=None): existing_action = None validate_legacy_action_update(submitted_action, existing_action) + validate_mcp_tool_pinning_for_save(action_data, existing_action) legacy_type = is_legacy_msgraph_type(action_data.get('type')) if legacy_type: action_data['type'] = 'msgraph' diff --git a/application/single_app/functions_group_actions.py b/application/single_app/functions_group_actions.py index 3c3b267ca..2960c64e2 100644 --- a/application/single_app/functions_group_actions.py +++ b/application/single_app/functions_group_actions.py @@ -36,6 +36,7 @@ ) from functions_settings import get_settings from functions_agent_delegation import validate_agent_action_for_scope +from functions_mcp_tool_pinning import validate_mcp_tool_pinning_for_save _NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") @@ -154,6 +155,7 @@ def save_group_action(group_id: str, action_data: Dict[str, Any], user_id: Optio except exceptions.CosmosResourceNotFoundError: pass validate_legacy_action_update(submitted_action, existing_action, 'group_id', group_id) + validate_mcp_tool_pinning_for_save(payload, existing_action) legacy_type = is_legacy_msgraph_type(payload.get('type')) if legacy_type: payload['type'] = 'msgraph' diff --git a/application/single_app/functions_mcp_fingerprint_metadata.py b/application/single_app/functions_mcp_fingerprint_metadata.py new file mode 100644 index 000000000..cead83b14 --- /dev/null +++ b/application/single_app/functions_mcp_fingerprint_metadata.py @@ -0,0 +1,61 @@ +# functions_mcp_fingerprint_metadata.py +"""Shared MCP prompt and tool fingerprint metadata normalization.""" + +from typing import Any, Dict, List + + +MCP_TOOL_FINGERPRINTS_FIELD = "mcp_tool_fingerprints" +MCP_TOOL_DRIFT_FIELD = "mcp_tool_drift" +MCP_PROMPTS_FIELD = "mcp_prompts" + + +def normalize_mcp_prompt_metadata(value: Any) -> List[Dict[str, Any]]: + """Return normalized MCP prompt metadata entries.""" + if not isinstance(value, list): + return [] + + prompts = [] + seen_names = set() + for prompt in value: + if not isinstance(prompt, dict): + continue + name = str(prompt.get("name") or "").strip() + if not name or name in seen_names: + continue + seen_names.add(name) + arguments = prompt.get("arguments") + if not isinstance(arguments, list): + arguments = [] + prompts.append({ + "name": name, + "description": str(prompt.get("description") or "").strip(), + "arguments": [ + argument for argument in arguments + if isinstance(argument, dict) and str(argument.get("name") or "").strip() + ], + }) + return prompts + + +def normalize_mcp_tool_fingerprints(value: Any) -> Dict[str, Any]: + """Return a normalized approved fingerprint object, or an empty dict.""" + if not isinstance(value, dict): + return {} + manifest_hash = str(value.get("manifest_hash") or "").strip() + tools = value.get("tools") if isinstance(value.get("tools"), dict) else {} + prompts = value.get("prompts") if isinstance(value.get("prompts"), dict) else {} + normalized = { + "manifest_hash": manifest_hash, + "tools": { + str(name): str(fingerprint) + for name, fingerprint in tools.items() + if str(name).strip() and str(fingerprint).strip() + }, + "prompts": { + str(name): str(fingerprint) + for name, fingerprint in prompts.items() + if str(name).strip() and str(fingerprint).strip() + }, + "discovered_at": str(value.get("discovered_at") or "").strip(), + } + return normalized if normalized["manifest_hash"] and normalized["discovered_at"] else {} diff --git a/application/single_app/functions_mcp_operations.py b/application/single_app/functions_mcp_operations.py index 6c00cee87..de0c818fa 100644 --- a/application/single_app/functions_mcp_operations.py +++ b/application/single_app/functions_mcp_operations.py @@ -8,6 +8,13 @@ from jsonschema.exceptions import SchemaError from functions_action_manifest import McpConfigurationError, McpStdioRemovedError +from functions_mcp_fingerprint_metadata import ( + MCP_PROMPTS_FIELD, + MCP_TOOL_DRIFT_FIELD, + MCP_TOOL_FINGERPRINTS_FIELD, + normalize_mcp_prompt_metadata, + normalize_mcp_tool_fingerprints, +) MCP_PLUGIN_TYPE = "mcp" @@ -601,6 +608,16 @@ def normalize_mcp_additional_fields(additional_fields): normalized_fields.get("allowed_tool_names") ) normalized_fields["mcp_tools"] = normalize_mcp_tool_metadata(normalized_fields.get("mcp_tools")) + normalized_fields[MCP_PROMPTS_FIELD] = normalize_mcp_prompt_metadata( + normalized_fields.get(MCP_PROMPTS_FIELD) + ) + fingerprints = normalize_mcp_tool_fingerprints(normalized_fields.get(MCP_TOOL_FINGERPRINTS_FIELD)) + if fingerprints: + normalized_fields[MCP_TOOL_FINGERPRINTS_FIELD] = fingerprints + elif MCP_TOOL_FINGERPRINTS_FIELD in normalized_fields: + normalized_fields.pop(MCP_TOOL_FINGERPRINTS_FIELD, None) + if normalized_fields.get(MCP_TOOL_DRIFT_FIELD) is None: + normalized_fields.pop(MCP_TOOL_DRIFT_FIELD, None) for retired_field in ("command", "args", "env"): normalized_fields.pop(retired_field, None) diff --git a/application/single_app/functions_mcp_tool_pinning.py b/application/single_app/functions_mcp_tool_pinning.py new file mode 100644 index 000000000..1728d50d2 --- /dev/null +++ b/application/single_app/functions_mcp_tool_pinning.py @@ -0,0 +1,338 @@ +# functions_mcp_tool_pinning.py +"""MCP tool and prompt fingerprint helpers.""" + +import hashlib +import json +import logging +from datetime import datetime, timezone +from typing import Any, Dict, Iterable, List, Optional, Tuple + +from functions_action_manifest import get_action_origin, resolve_action_type +from functions_appinsights import log_event +from functions_mcp_fingerprint_metadata import ( + MCP_PROMPTS_FIELD, + MCP_TOOL_DRIFT_FIELD, + MCP_TOOL_FINGERPRINTS_FIELD, + normalize_mcp_prompt_metadata, + normalize_mcp_tool_fingerprints, +) +from functions_mcp_operations import normalize_mcp_additional_fields, normalize_mcp_tool_metadata + + +MCP_DRIFT_NOTIFICATION_TYPE = "mcp_tool_drift_detected" +MCP_DRIFT_NOTIFICATION_TTL_SECONDS = 60 * 24 * 60 * 60 +MCP_DRIFT_NOTIFICATION_IDEMPOTENCY_KEY_MAX_LENGTH = 512 +MCP_PLUGIN_TYPE = "mcp" + + +def _canonical_hash(value: Any) -> str: + """Return the SHA-256 hash for stable canonical JSON.""" + encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _tool_payload(tool: Dict[str, Any]) -> Dict[str, Any]: + return { + "name": str(tool.get("original_name") or tool.get("name") or "").strip(), + "description": str(tool.get("description") or "").strip(), + "inputSchema": tool.get("inputSchema") if isinstance(tool.get("inputSchema"), dict) else tool.get("input_schema") if isinstance(tool.get("input_schema"), dict) else {}, + "outputSchema": tool.get("outputSchema") if isinstance(tool.get("outputSchema"), dict) else tool.get("output_schema") if isinstance(tool.get("output_schema"), dict) else {}, + "annotations": tool.get("annotations") if isinstance(tool.get("annotations"), dict) else {}, + } + + +def _prompt_payload(prompt: Dict[str, Any]) -> Dict[str, Any]: + return { + "name": str(prompt.get("name") or "").strip(), + "description": str(prompt.get("description") or "").strip(), + "arguments": prompt.get("arguments") if isinstance(prompt.get("arguments"), list) else [], + } + + +def compute_mcp_tool_hash(tool: Dict[str, Any]) -> str: + """Compute a stable fingerprint for one MCP tool.""" + return _canonical_hash(_tool_payload(tool)) + + +def compute_mcp_prompt_hash(prompt: Dict[str, Any]) -> str: + """Compute a stable fingerprint for one MCP prompt.""" + return _canonical_hash(_prompt_payload(prompt)) + + +def build_mcp_tool_fingerprints( + tools: Iterable[Dict[str, Any]], + prompts: Optional[Iterable[Dict[str, Any]]] = None, + *, + discovered_at: Optional[str] = None, +) -> Dict[str, Any]: + """Build the approved fingerprint manifest for discovered MCP tools and prompts.""" + normalized_tools = normalize_mcp_tool_metadata(list(tools or [])) + normalized_prompts = normalize_mcp_prompt_metadata(list(prompts or [])) + tool_hashes = { + tool["original_name"]: compute_mcp_tool_hash(tool) + for tool in normalized_tools + if tool.get("original_name") + } + prompt_hashes = { + prompt["name"]: compute_mcp_prompt_hash(prompt) + for prompt in normalized_prompts + if prompt.get("name") + } + manifest_hash = _canonical_hash({ + "tools": tool_hashes, + "prompts": prompt_hashes, + }) + return { + "manifest_hash": manifest_hash, + "tools": tool_hashes, + "prompts": prompt_hashes, + "discovered_at": discovered_at or datetime.now(timezone.utc).isoformat(), + } + + +def compare_mcp_fingerprints( + approved: Dict[str, Any], + current: Dict[str, Any], +) -> Dict[str, Any]: + """Compare approved and current fingerprint manifests.""" + approved = normalize_mcp_tool_fingerprints(approved) + current = normalize_mcp_tool_fingerprints(current) + approved_tools = approved.get("tools", {}) + current_tools = current.get("tools", {}) + approved_prompts = approved.get("prompts", {}) + current_prompts = current.get("prompts", {}) + + def diff(before: Dict[str, str], after: Dict[str, str]) -> Dict[str, List[str]]: + before_names = set(before.keys()) + after_names = set(after.keys()) + return { + "new": sorted(after_names - before_names), + "changed": sorted(name for name in before_names & after_names if before[name] != after[name]), + "removed": sorted(before_names - after_names), + } + + tool_diff = diff(approved_tools, current_tools) + prompt_diff = diff(approved_prompts, current_prompts) + return { + "manifest_hash": current.get("manifest_hash", ""), + "new": tool_diff["new"], + "changed": tool_diff["changed"], + "removed": tool_diff["removed"], + "prompts_new": prompt_diff["new"], + "prompts_changed": prompt_diff["changed"], + "prompts_removed": prompt_diff["removed"], + "has_drift": approved.get("manifest_hash") != current.get("manifest_hash"), + } + + +def filter_pinned_mcp_tools( + tools: Iterable[Dict[str, Any]], + fingerprints: Dict[str, Any], +) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + """Return only tools matching approved fingerprints, plus drift metadata.""" + normalized_tools = normalize_mcp_tool_metadata(list(tools or [])) + approved = normalize_mcp_tool_fingerprints(fingerprints) + approved_tools = approved.get("tools", {}) + if not approved_tools: + return normalized_tools, {"has_drift": False, "new": [], "changed": [], "removed": []} + allowed = [] + current_hashes = {} + for tool in normalized_tools: + name = tool.get("original_name") + if not name: + continue + current_hash = compute_mcp_tool_hash(tool) + current_hashes[name] = current_hash + if approved_tools.get(name) == current_hash: + allowed.append(tool) + current = build_mcp_tool_fingerprints(normalized_tools, []) + current["tools"] = current_hashes + current["manifest_hash"] = _canonical_hash({"tools": current_hashes, "prompts": approved.get("prompts", {})}) + return allowed, compare_mcp_fingerprints(approved, current) + + +def validate_mcp_tool_pinning_for_save(action: Dict[str, Any], existing_action: Optional[Dict[str, Any]] = None) -> None: + """Enforce MCP fingerprint approval for new or connection-changing saves.""" + if not isinstance(action, dict) or resolve_action_type(action) != MCP_PLUGIN_TYPE: + return + fields = action.get("additionalFields") if isinstance(action.get("additionalFields"), dict) else {} + existing_fields = existing_action.get("additionalFields") if isinstance(existing_action, dict) and isinstance(existing_action.get("additionalFields"), dict) else {} + fingerprints = normalize_mcp_tool_fingerprints(fields.get(MCP_TOOL_FINGERPRINTS_FIELD)) + existing_fingerprints = normalize_mcp_tool_fingerprints(existing_fields.get(MCP_TOOL_FINGERPRINTS_FIELD)) + relevant_keys = ( + "endpoint", + "identity_id", + "auth", + "server_profile", + "preconfiguration_id", + "transport", + "auth_method", + "api_key_header_name", + "custom_headers", + "load_tools", + "load_prompts", + "mcp_tools", + MCP_PROMPTS_FIELD, + "allowed_tool_names", + ) + + def relevant(source: Dict[str, Any], additional: Dict[str, Any]) -> Dict[str, Any]: + return { + "endpoint": source.get("endpoint"), + "identity_id": source.get("identity_id"), + "auth": source.get("auth"), + **{key: additional.get(key) for key in relevant_keys if key not in {"endpoint", "identity_id", "auth"}}, + } + + changed = existing_action is None or ( + relevant(action, normalize_mcp_additional_fields(fields)) + != relevant(existing_action, normalize_mcp_additional_fields(existing_fields)) + ) + if existing_action is not None and not existing_fingerprints and not changed: + return + if not fingerprints and (existing_action is None or changed or existing_fingerprints): + raise ValueError("Discover and approve MCP tools before saving this action.") + + current = build_mcp_tool_fingerprints( + normalize_mcp_tool_metadata(fields.get("mcp_tools")), + normalize_mcp_prompt_metadata(fields.get(MCP_PROMPTS_FIELD)), + discovered_at=fingerprints.get("discovered_at"), + ) + if fingerprints.get("tools") != current.get("tools") or fingerprints.get("prompts") != current.get("prompts"): + raise ValueError("MCP tool fingerprints do not match the approved discovery metadata.") + if fields.get(MCP_TOOL_DRIFT_FIELD) is None: + fields.pop(MCP_TOOL_DRIFT_FIELD, None) + + +def persist_mcp_tool_drift(action: Dict[str, Any], drift: Dict[str, Any]) -> None: + """Persist MCP drift metadata and notify the owner when possible.""" + if not isinstance(action, dict) or not drift.get("has_drift"): + return + origin = get_action_origin(action) + if origin is None: + return + manifest_hash = str(drift.get("manifest_hash") or "").strip() + if not manifest_hash: + return + detected_at = datetime.now(timezone.utc).isoformat() + drift_doc = { + "detected_at": detected_at, + "manifest_hash": manifest_hash, + "new": drift.get("new", []), + "changed": drift.get("changed", []), + "removed": drift.get("removed", []), + "prompts_new": drift.get("prompts_new", []), + "prompts_changed": drift.get("prompts_changed", []), + "prompts_removed": drift.get("prompts_removed", []), + } + try: + fields = action.setdefault("additionalFields", {}) + fields[MCP_TOOL_DRIFT_FIELD] = drift_doc + if origin.scope_type == "personal": + from config import cosmos_personal_actions_container + stored = cosmos_personal_actions_container.read_item(item=origin.action_id, partition_key=origin.scope_id) + stored.setdefault("additionalFields", {})[MCP_TOOL_DRIFT_FIELD] = drift_doc + cosmos_personal_actions_container.replace_item(item=origin.action_id, body=stored) + elif origin.scope_type == "group": + from config import cosmos_group_actions_container + stored = cosmos_group_actions_container.read_item(item=origin.action_id, partition_key=origin.scope_id) + stored.setdefault("additionalFields", {})[MCP_TOOL_DRIFT_FIELD] = drift_doc + cosmos_group_actions_container.replace_item(item=origin.action_id, body=stored) + elif origin.scope_type == "global": + from config import cosmos_global_actions_container + stored = cosmos_global_actions_container.read_item(item=origin.action_id, partition_key=origin.action_id) + stored.setdefault("additionalFields", {})[MCP_TOOL_DRIFT_FIELD] = drift_doc + cosmos_global_actions_container.replace_item(item=origin.action_id, body=stored) + except Exception as exc: + log_event( + "[MCP_PINNING] Failed to persist MCP tool drift", + level=logging.WARNING, + extra={"scope_type": origin.scope_type, "action_id": origin.action_id, "error_type": type(exc).__name__}, + ) + + try: + from config import cosmos_notifications_container + + notification_doc = { + "user_id": origin.scope_id if origin.scope_type == "personal" else None, + "group_id": origin.scope_id if origin.scope_type == "group" else None, + "public_workspace_id": None, + "scope": "assignment" if origin.scope_type == "global" else origin.scope_type, + "notification_type": MCP_DRIFT_NOTIFICATION_TYPE, + "title": "MCP tools changed — review required", + "message": "An MCP server changed its tool or prompt manifest. Review and re-approve the action before new or changed tools can run.", + "created_at": detected_at, + "ttl": MCP_DRIFT_NOTIFICATION_TTL_SECONDS, + "read_by": [], + "dismissed_by": [], + "link_url": "", + "link_context": {}, + "metadata": { + "action_id": origin.action_id, + "scope_type": origin.scope_type, + "scope_id": origin.scope_id, + "manifest_hash": manifest_hash, + }, + "assignment": {"roles": ["Admin", "ControlCenterAdmin"]} + if origin.scope_type == "global" + else None, + } + idempotency_key = ( + f"mcp-tool-drift:{origin.scope_type}:{origin.scope_id}:" + f"{origin.action_id}:{manifest_hash}" + ) + if len(idempotency_key) > MCP_DRIFT_NOTIFICATION_IDEMPOTENCY_KEY_MAX_LENGTH: + raise ValueError("The MCP drift notification retry key is too long.") + retry_identity = { + "version": 1, + "key": idempotency_key, + **{ + field: notification_doc.get(field) + for field in ( + "scope", + "user_id", + "group_id", + "public_workspace_id", + "notification_type", + "assignment", + ) + }, + } + encoded_identity = json.dumps( + retry_identity, + sort_keys=True, + ensure_ascii=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + notification_doc["id"] = ( + f"notification-{hashlib.sha256(encoded_identity).hexdigest()}" + ) + try: + cosmos_notifications_container.create_item(notification_doc) + except Exception as exc: + if getattr(exc, "status_code", None) != 409: + raise + existing = cosmos_notifications_container.read_item( + item=notification_doc["id"], + partition_key=notification_doc.get("user_id"), + ) + if not isinstance(existing, dict) or any( + existing.get(field) != notification_doc.get(field) + for field in ( + "id", + "scope", + "user_id", + "group_id", + "public_workspace_id", + "notification_type", + "assignment", + ) + ): + raise ValueError("The stored MCP drift notification does not match its retry identity.") + except Exception as exc: + log_event( + "[MCP_PINNING] Failed to deliver MCP tool drift notification", + level=logging.WARNING, + extra={"scope_type": origin.scope_type, "action_id": origin.action_id, "error_type": type(exc).__name__}, + ) diff --git a/application/single_app/functions_notifications.py b/application/single_app/functions_notifications.py index b01bf4ecb..cd13df387 100644 --- a/application/single_app/functions_notifications.py +++ b/application/single_app/functions_notifications.py @@ -234,6 +234,10 @@ 'icon': 'bi-trash', 'color': 'secondary' }, + 'mcp_tool_drift_detected': { + 'icon': 'bi-shield-exclamation', + 'color': 'warning' + }, 'generated_file_approval_pending': { 'icon': 'bi-file-earmark-lock', 'color': 'warning' diff --git a/application/single_app/functions_personal_actions.py b/application/single_app/functions_personal_actions.py index 283eec1c5..8397d8f2f 100644 --- a/application/single_app/functions_personal_actions.py +++ b/application/single_app/functions_personal_actions.py @@ -48,6 +48,7 @@ validate_action_configuration, validate_scoped_mcp_action, ) +from functions_mcp_tool_pinning import validate_mcp_tool_pinning_for_save from functions_workspace_identities import ( WORKSPACE_IDENTITY_SCOPE_PERSONAL, hydrate_action_identity_reference, @@ -250,6 +251,7 @@ def _save_personal_action(user_id, action_data, enforce_governance=True, migrati if action_data.get('id'): existing_action = _read_personal_action_record(user_id, action_data['id']) validate_legacy_action_update(submitted_action, existing_action, 'user_id', user_id) + validate_mcp_tool_pinning_for_save(action_data, existing_action) if legacy_type: action_data['type'] = 'msgraph' elif not action_data.get('id') and action_data.get('name'): diff --git a/application/single_app/functions_workspace_authoring.py b/application/single_app/functions_workspace_authoring.py index 62db0aa74..c9dd19a91 100644 --- a/application/single_app/functions_workspace_authoring.py +++ b/application/single_app/functions_workspace_authoring.py @@ -1892,6 +1892,9 @@ def build_action_editor_types(discovered_types): ), "allowed_auth_types": sorted(validation.get_allowed_auth_types_for_plugin_type(action_type)), } + for optional_field in ("hidden", "legacy", "capabilities", "defaults", "graph_endpoint"): + if optional_field in definition: + record[optional_field] = definition[optional_field] for field, suffix in ( ("additional_fields_schema", "additional_settings"), ("metadata_schema", "metadata"), ): diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index c2681bf6d..4bf37615d 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -149,6 +149,10 @@ get_mcp_error_http_status, normalize_mcp_additional_fields, ) +from functions_mcp_tool_pinning import ( + build_mcp_tool_fingerprints, + validate_mcp_tool_pinning_for_save, +) from functions_mcp_destinations import ( McpDestinationPolicyError, assert_mcp_destination_allowed, @@ -406,7 +410,7 @@ def discover_plugin_types(include_legacy=False): isinstance(obj, type) and issubclass(obj, BasePlugin) and obj is not BasePlugin - and (module_name.replace('_plugin', '') not in M365_PLUGIN_TYPES or obj.__module__ == module.__name__) + and obj.__module__ == module.__name__ and not getattr(obj, 'internal_only', False) ): # Use the type string as in the manifest (e.g., 'blob_storage') @@ -425,10 +429,37 @@ def discover_plugin_types(include_legacy=False): types.add(module_name.replace('_plugin', '')) return types if include_legacy else {plugin_type for plugin_type in types if not is_legacy_msgraph_type(plugin_type)} +HIDDEN_NEW_ACTION_PLUGIN_TYPES = { + DATABRICKS_LEGACY_TABLE_PLUGIN_TYPE, + 'embedding_model', + 'queue_storage', + 'ui_test', + 'sql_schema', +} + + +def _catalog_graph_endpoint(): + return get_graph_base_url().removesuffix('/v1.0') + + +def _catalog_entry(plugin_type, class_name, display, description, **extra): + entry = { + 'type': plugin_type, + 'class': class_name, + 'display': display, + 'description': description, + } + if plugin_type in HIDDEN_NEW_ACTION_PLUGIN_TYPES: + entry['hidden'] = True + entry['legacy'] = True + entry.update(extra) + return entry + + def get_plugin_types(allowed_type_filter=None): # Path to the plugin types directory (semantic_kernel_plugins) plugintypes_dir = os.path.join(current_app.root_path, 'semantic_kernel_plugins') - types = [] + type_entries = {} debug_log = [] for fname in os.listdir(plugintypes_dir): if fname.endswith('_plugin.py') and fname != 'base_plugin.py' and not fname.startswith('_'): @@ -454,32 +485,36 @@ def get_plugin_types(allowed_type_filter=None): isinstance(obj, type) and issubclass(obj, BasePlugin) and obj is not BasePlugin - and (module_type not in M365_PLUGIN_TYPES or obj.__module__ == module.__name__) + and obj.__module__ == module.__name__ and not getattr(obj, 'internal_only', False) ): found = True + if module_type in type_entries: + debug_log.append(f"Skipping duplicate plugin type entry: {module_type}") + continue if module_type in M365_PLUGIN_TYPES: definition = get_m365_action_definition(module_type) - types.append({ - 'type': module_type, - 'class': definition['class_name'], - 'display': definition['display_name'], - 'description': definition['description'], - 'source': definition['source'], - 'capabilities': definition['capabilities'], - 'defaults': get_m365_default_config(module_type)['additionalFields'], - }) + type_entries[module_type] = _catalog_entry( + module_type, + definition['class_name'], + definition['display_name'], + definition['description'], + source=definition['source'], + capabilities=definition['capabilities'], + defaults=get_m365_default_config(module_type)['additionalFields'], + graph_endpoint=_catalog_graph_endpoint(), + ) continue # Special handling for OpenAPI plugin that requires spec path if 'openapi' in module_name.lower(): display_name = "OpenAPI" description = "Connect to an API using OpenAPI JSON/YAML content and configurable authentication. Download hosted specifications before uploading them." - types.append({ - 'type': module_type, - 'class': attr, - 'display': display_name, - 'description': description - }) + type_entries[module_type] = _catalog_entry( + module_type, + attr, + display_name, + description, + ) continue # Try to get display name from plugin instance @@ -688,15 +723,27 @@ def get_plugin_types(allowed_type_filter=None): description = f"Plugin for {display_name.lower()} functionality" debug_log.append(f"Complete failure to instantiate {attr}: {e}. Using final fallback.") - types.append({ - 'type': module_type, - 'class': attr, - 'display': display_name, - 'description': description - }) + if module_type == 'sql_query': + display_name = 'SQL Database' + description = ( + 'Query a SQL database with governed read controls. ' + 'Schema discovery is included automatically for agents that need table context.' + ) + elif module_type == DATABRICKS_PLUGIN_TYPE: + description = ( + 'Run governed read-only SQL Warehouse queries against Azure Databricks, ' + 'including catalog and schema defaults for agent context.' + ) + elif module_type == DATABRICKS_LEGACY_TABLE_PLUGIN_TYPE: + description = ( + 'Legacy Databricks table action retained for existing configurations. ' + 'Create new Databricks actions with the Databricks SQL Warehouse connector.' + ) + type_entries[module_type] = _catalog_entry(module_type, attr, display_name, description) if not found: debug_log.append(f"No valid plugin class found in {fname}") # Log the debug output to the server log + types = list(type_entries.values()) if callable(allowed_type_filter): types = [plugin_type for plugin_type in types if allowed_type_filter(plugin_type.get('type'))] @@ -1348,10 +1395,10 @@ def get_user_plugins(): def _prepare_personal_action_for_editor(user_id, plugin, settings, existing): validate_legacy_action_update(plugin, existing, 'user_id', user_id) - return _prepare_personal_action_payload(user_id, plugin, editor=True) + return _prepare_personal_action_payload(user_id, plugin, editor=True, existing=existing) -def _prepare_personal_action_payload(user_id, plugin, *, editor=False): +def _prepare_personal_action_payload(user_id, plugin, *, editor=False, existing=None): """Clean, default and validate a single personal action. This is the per-action half of the bulk save, factored out so the per-item create and @@ -1406,6 +1453,10 @@ def _prepare_personal_action_payload(user_id, plugin, *, editor=False): is_valid, validation_errors = PluginHealthChecker.validate_plugin_manifest(plugin_to_save, plugin_type) if not is_valid: return None, (jsonify({'error': f'Plugin validation failed: {"; ".join(validation_errors)}'}), 400) + try: + validate_mcp_tool_pinning_for_save(plugin_to_save, existing) + except ValueError: + return None, (jsonify({'error': 'Discover and approve MCP tools before saving this action.'}), 400) try: ensure_action_type_access('governance_user_actions', user_id, plugin_type, 'personal') @@ -1477,6 +1528,10 @@ def _prepare_group_action_payload(user_id, group_id, plugin, settings, existing) is_valid, validation_errors = PluginHealthChecker.validate_plugin_manifest(plugin_to_save, plugin_type) if not is_valid: return None, (jsonify({'error': f'Plugin validation failed: {"; ".join(validation_errors)}'}), 400) + try: + validate_mcp_tool_pinning_for_save(plugin_to_save, existing) + except ValueError: + return None, (jsonify({'error': 'Discover and approve MCP tools before saving this action.'}), 400) try: ensure_action_type_access('governance_group_actions', user_id, plugin_type, 'group') @@ -1554,6 +1609,10 @@ def _prepare_global_action_payload(user_id, plugin, settings, existing): is_valid, validation_errors = PluginHealthChecker.validate_plugin_manifest(plugin_to_save, plugin_type) if not is_valid: return None, (jsonify({'error': f'Plugin validation failed: {"; ".join(validation_errors)}'}), 400) + try: + validate_mcp_tool_pinning_for_save(plugin_to_save, existing) + except ValueError: + return None, (jsonify({'error': 'Discover and approve MCP tools before saving this action.'}), 400) try: _enforce_mcp_destination_policy( @@ -1689,7 +1748,7 @@ def update_user_plugin(action_id): # body; that keeps a rename from orphaning the existing record. merged['id'] = existing.get('id') - plugin_to_save, error = _prepare_personal_action_payload(user_id, merged) + plugin_to_save, error = _prepare_personal_action_payload(user_id, merged, existing=existing) if error: return error @@ -1805,7 +1864,8 @@ def set_user_plugins(): and legacy_action is None ): continue # Skip global plugins - plugin_to_save, error = _prepare_personal_action_payload(user_id, plugin) + existing_action = current_actions_by_id.get(submitted_id) if submitted_id else None + plugin_to_save, error = _prepare_personal_action_payload(user_id, plugin, existing=existing_action) if error: return error @@ -2880,6 +2940,10 @@ def discover_mcp_tools(): probe_result = asyncio.run(McpPluginFactory.probe_server_from_config(discovery_manifest, origin=origin)) tools = probe_result.get('tools', []) if isinstance(probe_result, dict) else [] + prompts = probe_result.get('prompts', []) if isinstance(probe_result, dict) else [] + fingerprints = probe_result.get('fingerprints') if isinstance(probe_result, dict) else None + if not fingerprints: + fingerprints = build_mcp_tool_fingerprints(tools, prompts) log_event( "[MCP_DISCOVERY] Completed", extra=_build_mcp_discovery_log_context( @@ -2903,6 +2967,9 @@ def discover_mcp_tools(): 'mcp_operation_id': mcp_operation_id, 'tool_count': len(tools), 'tools': tools, + 'prompt_count': len(prompts), + 'prompts': prompts, + 'fingerprints': fingerprints, 'capabilities': probe_result.get('capabilities', {}) if isinstance(probe_result, dict) else {}, 'warnings': probe_result.get('warnings', []) if isinstance(probe_result, dict) else [], 'transport': probe_result.get('transport') if isinstance(probe_result, dict) else None, diff --git a/application/single_app/semantic_kernel_plugins/document_search_plugin.py b/application/single_app/semantic_kernel_plugins/document_search_plugin.py index cbdc5647a..f37012d80 100644 --- a/application/single_app/semantic_kernel_plugins/document_search_plugin.py +++ b/application/single_app/semantic_kernel_plugins/document_search_plugin.py @@ -1,13 +1,19 @@ # document_search_plugin.py -from typing import Annotated, Any, Dict +from typing import Annotated, Any, Dict, List, Optional, Union from semantic_kernel.functions import kernel_function from content_screening.contracts import ScreeningError from functions_authentication import get_current_user_id from functions_agent_document_citations import annotate_document_search_payload -from functions_search import SEARCH_DEFAULT_TOP_N, SEARCH_MAX_TOP_N, normalize_search_scope, normalize_search_top_n +from functions_search import ( + SEARCH_DEFAULT_TOP_N, + SEARCH_MAX_TOP_N, + normalize_search_id_list, + normalize_search_scope, + normalize_search_top_n, +) from functions_search_service import ( SUMMARY_DEFAULT_FINAL_TARGET, SUMMARY_DEFAULT_WINDOW_SUMMARY_TARGET, @@ -20,6 +26,87 @@ from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger +DOCUMENT_SEARCH_SCOPES = ("personal", "group", "public") +WINDOW_TARGET_LENGTH_LIMITS = (1, 10) +FINAL_TARGET_LENGTH_LIMITS = (1, 20) + + +class DocumentSearchScopeError(ValueError): + """Raised when an action configuration disallows the requested document scope.""" + + +def normalize_allowed_search_scopes(additional_fields: Optional[Dict[str, Any]]) -> List[str]: + if not isinstance(additional_fields, dict) or "allowed_scopes" not in additional_fields: + return list(DOCUMENT_SEARCH_SCOPES) + + raw_scopes = additional_fields.get("allowed_scopes") + if not isinstance(raw_scopes, list): + return list(DOCUMENT_SEARCH_SCOPES) + + allowed_scopes = [] + for raw_scope in raw_scopes: + scope = str(raw_scope or "").strip().lower() + if scope in DOCUMENT_SEARCH_SCOPES and scope not in allowed_scopes: + allowed_scopes.append(scope) + return allowed_scopes + + +def resolve_allowed_scope_requests(requested_scope: str, allowed_scopes: List[str]) -> List[str]: + normalized_scope = normalize_search_scope(requested_scope) + allowed_scope_set = set(allowed_scopes) + if normalized_scope != "all": + return [normalized_scope] if normalized_scope in allowed_scope_set else [] + + if all(scope in allowed_scope_set for scope in DOCUMENT_SEARCH_SCOPES): + return ["all"] + + return [scope for scope in DOCUMENT_SEARCH_SCOPES if scope in allowed_scope_set] + + +def intersect_allowed_search_ids(runtime_ids: Any, allowed_ids: Any) -> Union[List[str], str]: + normalized_allowed_ids = normalize_search_id_list(allowed_ids) + if not normalized_allowed_ids: + return runtime_ids + + normalized_runtime_ids = normalize_search_id_list(runtime_ids) + if not normalized_runtime_ids: + return normalized_allowed_ids + + allowed_id_set = set(normalized_allowed_ids) + return [scope_id for scope_id in normalized_runtime_ids if scope_id in allowed_id_set] + + +def parse_page_target_length(value: Any, min_pages: int, max_pages: int) -> Optional[int]: + text_value = str(value or "").strip().lower() + if not text_value: + return None + + if text_value.endswith("page"): + text_value = text_value[:-4].strip() + elif text_value.endswith("pages"): + text_value = text_value[:-5].strip() + + try: + pages = int(text_value) + except (TypeError, ValueError): + return None + + if pages < min_pages: + return min_pages + return min(pages, max_pages) + + +def normalize_summary_target_length(value: Any, fallback_value: str, min_pages: int, max_pages: int) -> str: + text_value = str(value or "").strip() + if not text_value: + text_value = fallback_value + + parsed_pages = parse_page_target_length(text_value, min_pages, max_pages) + if parsed_pages is None: + return text_value + return f"{parsed_pages} pages" + + class DocumentSearchPlugin(BasePlugin): def __init__(self, manifest: Dict[str, Any] = None): super().__init__(manifest) @@ -95,6 +182,14 @@ def _resolve_doc_scope(self, requested_scope: str) -> str: return normalize_search_scope(self._get_additional_fields().get('default_doc_scope', 'all')) + def _resolve_doc_scope_requests(self, requested_scope: str) -> List[str]: + scope = self._resolve_doc_scope(requested_scope) + allowed_scopes = normalize_allowed_search_scopes(self._get_additional_fields()) + scope_requests = resolve_allowed_scope_requests(scope, allowed_scopes) + if not scope_requests: + raise DocumentSearchScopeError("Document search scope is not allowed for this action.") + return scope_requests + def _resolve_top_n(self, requested_top_n: int) -> int: top_n_value = self._coerce_positive_int(requested_top_n) if top_n_value: @@ -127,9 +222,107 @@ def _resolve_focus_instructions(self, requested_focus_instructions: str) -> str: def _resolve_target_length(self, requested_value: str, manifest_key: str, fallback_value: str) -> str: if str(requested_value or '').strip(): - return requested_value + raw_value = requested_value + else: + raw_value = self._get_additional_fields().get(manifest_key, fallback_value) + + if manifest_key == 'default_window_target_length': + return normalize_summary_target_length( + raw_value, + fallback_value, + WINDOW_TARGET_LENGTH_LIMITS[0], + WINDOW_TARGET_LENGTH_LIMITS[1], + ) + if manifest_key == 'default_final_target_length': + return normalize_summary_target_length( + raw_value, + fallback_value, + FINAL_TARGET_LENGTH_LIMITS[0], + FINAL_TARGET_LENGTH_LIMITS[1], + ) + return str(raw_value or fallback_value) + + def _resolve_allowed_group_ids(self, active_group_ids: Any) -> Union[List[str], str]: + return intersect_allowed_search_ids( + active_group_ids, + self._get_additional_fields().get('allowed_group_ids'), + ) + + def _resolve_allowed_public_workspace_ids(self, active_public_workspace_id: Any) -> Union[List[str], str]: + return intersect_allowed_search_ids( + active_public_workspace_id, + self._get_additional_fields().get('allowed_public_workspace_ids'), + ) + + def _run_scope_limited_search( + self, + query, + user_id, + top_n, + scope_requests, + document_ids, + tags_filter, + active_group_ids, + active_public_workspace_id, + ): + if len(scope_requests) == 1: + return run_document_search( + query=query, + user_id=user_id, + top_n=top_n, + doc_scope=scope_requests[0], + document_ids=document_ids, + tags_filter=tags_filter, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + include_all_public_workspaces=scope_requests[0] == "public", + ) + + merged_results = [] + for scope in scope_requests: + scoped_payload = run_document_search( + query=query, + user_id=user_id, + top_n=top_n, + doc_scope=scope, + document_ids=document_ids, + tags_filter=tags_filter, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + include_all_public_workspaces=scope == "public", + ) + merged_results.extend(scoped_payload.get("results", [])) + + merged_results = merged_results[:top_n] + unique_document_ids = { + result.get("document_id") + for result in merged_results + if result.get("document_id") + } + return { + "query": query, + "scope": "all", + "allowed_scopes": scope_requests, + "top_n": top_n, + "document_ids": normalize_search_id_list(document_ids), + "tags_filter": normalize_search_id_list(tags_filter), + "group_ids": normalize_search_id_list(active_group_ids), + "active_public_workspace_id": active_public_workspace_id, + "result_count": len(merged_results), + "document_count": len(unique_document_ids), + "results": merged_results, + } - return self._get_additional_fields().get(manifest_key, fallback_value) + def _call_scope_limited_document_operation(self, operation, scope_requests, **kwargs): + last_lookup_error = None + for scope in scope_requests: + try: + return operation(doc_scope=scope, **kwargs) + except LookupError as error: + last_lookup_error = error + if last_lookup_error: + raise last_lookup_error + raise DocumentSearchScopeError("Document search scope is not allowed for this action.") # bac-check: ignore - run_document_search resolves requested workspace ids through current-user scope resolvers. @plugin_function_logger('DocumentSearchPlugin') @@ -152,20 +345,22 @@ def search_documents( ) -> Annotated[dict, 'Search results and request metadata.']: try: return annotate_document_search_payload( - run_document_search( + self._run_scope_limited_search( query=query, user_id=self._get_user_id(), top_n=self._resolve_top_n(top_n), - doc_scope=self._resolve_doc_scope(doc_scope), + scope_requests=self._resolve_doc_scope_requests(doc_scope), document_ids=document_ids, tags_filter=tags_filter, - active_group_ids=active_group_ids, - active_public_workspace_id=active_public_workspace_id, + active_group_ids=self._resolve_allowed_group_ids(active_group_ids), + active_public_workspace_id=self._resolve_allowed_public_workspace_ids(active_public_workspace_id), ), 'search_documents', ) except ScreeningError as error: return {"error": error.public_message, "error_code": error.code, "status_code": error.status_code} + except DocumentSearchScopeError as error: + return {'error': str(error)} except Exception as e: return {'error': str(e)} @@ -191,12 +386,13 @@ def retrieve_document_chunks( ) -> Annotated[dict, 'Ordered chunks and window metadata for one document.']: try: return annotate_document_search_payload( - get_document_chunks_payload( + self._call_scope_limited_document_operation( + get_document_chunks_payload, + self._resolve_doc_scope_requests(doc_scope), document_id=document_id, user_id=self._get_user_id(), - doc_scope=self._resolve_doc_scope(doc_scope), - active_group_ids=active_group_ids, - active_public_workspace_id=active_public_workspace_id, + active_group_ids=self._resolve_allowed_group_ids(active_group_ids), + active_public_workspace_id=self._resolve_allowed_public_workspace_ids(active_public_workspace_id), window_unit=self._resolve_window_unit(window_unit), window_size=self._resolve_optional_window_value(window_size, 'default_window_size'), window_percent=self._resolve_optional_window_value(window_percent, 'default_window_percent'), @@ -206,6 +402,8 @@ def retrieve_document_chunks( ) except ScreeningError as error: return {"error": error.public_message, "error_code": error.code, "status_code": error.status_code} + except DocumentSearchScopeError as error: + return {'error': str(error)} except Exception as e: return {'error': str(e)} @@ -233,12 +431,13 @@ def summarize_document( ) -> Annotated[dict, 'Final summary text plus stage and window metadata.']: try: return annotate_document_search_payload( - summarize_document_content( + self._call_scope_limited_document_operation( + summarize_document_content, + self._resolve_doc_scope_requests(doc_scope), document_id=document_id, user_id=self._get_user_id(), - doc_scope=self._resolve_doc_scope(doc_scope), - active_group_ids=active_group_ids, - active_public_workspace_id=active_public_workspace_id, + active_group_ids=self._resolve_allowed_group_ids(active_group_ids), + active_public_workspace_id=self._resolve_allowed_public_workspace_ids(active_public_workspace_id), focus_instructions=self._resolve_focus_instructions(focus_instructions), final_target_length=self._resolve_target_length(final_target_length, 'default_final_target_length', SUMMARY_DEFAULT_FINAL_TARGET), window_target_length=self._resolve_target_length(window_target_length, 'default_window_target_length', SUMMARY_DEFAULT_WINDOW_SUMMARY_TARGET), @@ -250,5 +449,7 @@ def summarize_document( ) except ScreeningError as error: return {"error": error.public_message, "error_code": error.code, "status_code": error.status_code} + except DocumentSearchScopeError as error: + return {'error': str(error)} except Exception as e: return {'error': str(e)} \ No newline at end of file diff --git a/application/single_app/semantic_kernel_plugins/mcp_plugin.py b/application/single_app/semantic_kernel_plugins/mcp_plugin.py index be6143e8c..cff6a3860 100644 --- a/application/single_app/semantic_kernel_plugins/mcp_plugin.py +++ b/application/single_app/semantic_kernel_plugins/mcp_plugin.py @@ -25,6 +25,11 @@ normalize_mcp_tool_call_arguments, validate_mcp_tool_arguments, ) +from functions_mcp_tool_pinning import ( + MCP_TOOL_FINGERPRINTS_FIELD, + filter_pinned_mcp_tools, + normalize_mcp_tool_fingerprints, +) from semantic_kernel_plugins.base_plugin import BasePlugin from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger @@ -43,9 +48,12 @@ def __init__( self.manifest["type"] = MCP_PLUGIN_TYPE self.manifest["additionalFields"] = self._additional_fields self._allowed_tool_names = set(self._additional_fields.get("allowed_tool_names") or []) - self._tools = self._filter_tools( - normalize_mcp_tool_metadata(self._additional_fields.get("mcp_tools", [])) + self._fingerprints = normalize_mcp_tool_fingerprints( + self._additional_fields.get(MCP_TOOL_FINGERPRINTS_FIELD) ) + self._tools = self._filter_tools(self._approved_tools( + normalize_mcp_tool_metadata(self._additional_fields.get("mcp_tools", [])) + )) def _validate_manifest(self): if not isinstance(self.manifest, dict) or resolve_action_type(self.manifest) != MCP_PLUGIN_TYPE: @@ -66,6 +74,15 @@ def _filter_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: or tool.get("function_name") in self._allowed_tool_names ] + def _approved_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + approved_tools, drift = filter_pinned_mcp_tools(tools, self._fingerprints) + if drift.get("has_drift"): + debug_print( + "[MCP_PLUGIN] Cached MCP tool metadata does not match approved fingerprints; " + f"changed={drift.get('changed')} new={drift.get('new')} removed={drift.get('removed')}" + ) + return approved_tools + @property def display_name(self) -> str: return "Model Context Protocol" @@ -150,6 +167,7 @@ def list_configured_tools(self) -> dict: "success": True, "transport": self._additional_fields.get("transport"), "allowed_tool_names": sorted(self._allowed_tool_names), + "fingerprint_manifest_hash": self._fingerprints.get("manifest_hash"), "tool_count": len(self._tools), "tools": self._tools, } diff --git a/application/single_app/semantic_kernel_plugins/mcp_plugin_factory.py b/application/single_app/semantic_kernel_plugins/mcp_plugin_factory.py index eb826a114..9b6c526af 100644 --- a/application/single_app/semantic_kernel_plugins/mcp_plugin_factory.py +++ b/application/single_app/semantic_kernel_plugins/mcp_plugin_factory.py @@ -37,6 +37,14 @@ normalize_mcp_tool_metadata, validate_mcp_endpoint_for_transport, ) +from functions_mcp_tool_pinning import ( + MCP_TOOL_FINGERPRINTS_FIELD, + build_mcp_tool_fingerprints, + compare_mcp_fingerprints, + normalize_mcp_prompt_metadata, + normalize_mcp_tool_fingerprints, + persist_mcp_tool_drift, +) from functions_mcp_destinations import ( McpDestinationPolicyError, build_mcp_destination_log_context, @@ -172,19 +180,27 @@ async def _probe_server_once(cls, config: Dict[str, Any], *, origin=None) -> Dic raise ValueError("MCP server did not create a session.") tools = await cls._list_tools_from_session(connector.session) + prompts, prompt_warning = await cls._list_prompts_from_session(connector.session) capabilities = { "tools": bool(tools), + "prompts": bool(prompts), "prompts_requested": bool(additional_fields.get("load_prompts")), "resources": False, "connector_type": connector.__class__.__name__, "session_type": connector.session.__class__.__name__, } warnings = build_mcp_tool_metadata_warnings(tools, additional_fields) + if prompt_warning: + warnings.append(prompt_warning) + fingerprints = build_mcp_tool_fingerprints(tools, prompts) result = { "transport": additional_fields.get("transport"), "auth_method": additional_fields.get("auth_method"), "tool_count": len(tools), "tools": tools, + "prompt_count": len(prompts), + "prompts": prompts, + "fingerprints": fingerprints, "capabilities": capabilities, "warnings": warnings, } @@ -279,6 +295,34 @@ async def _list_tools_from_session(cls, session) -> List[Dict[str, Any]]: }) return normalize_mcp_tool_metadata(raw_tools) + @classmethod + async def _list_prompts_from_session(cls, session) -> tuple[List[Dict[str, Any]], str]: + list_prompts = getattr(session, "list_prompts", None) + if not callable(list_prompts): + return [], "This MCP client does not support prompt discovery; prompt fingerprints are empty." + try: + prompt_list = await list_prompts() + except AttributeError: + return [], "This MCP client does not support prompt discovery; prompt fingerprints are empty." + except NotImplementedError: + return [], "This MCP client does not support prompt discovery; prompt fingerprints are empty." + prompts = [] + for prompt in getattr(prompt_list, "prompts", []) if prompt_list else []: + raw_arguments = getattr(prompt, "arguments", None) or [] + arguments = [] + for argument in raw_arguments: + arguments.append({ + "name": getattr(argument, "name", ""), + "description": getattr(argument, "description", "") or "", + "required": bool(getattr(argument, "required", False)), + }) + prompts.append({ + "name": getattr(prompt, "name", ""), + "description": getattr(prompt, "description", "") or "", + "arguments": arguments, + }) + return normalize_mcp_prompt_metadata(prompts), "" + @classmethod async def call_tool_from_config( cls, @@ -291,6 +335,7 @@ async def call_tool_from_config( """Connect to an MCP server, invoke one tool, and normalize the result.""" manifest = cls._normalize_manifest(config) configured_tool = cls._find_configured_tool(manifest, tool_name) + await cls._enforce_pinned_manifest(manifest, origin=origin) normalized_arguments = ( normalize_mcp_tool_call_arguments(configured_tool, arguments) if configured_tool @@ -302,6 +347,47 @@ async def call_tool_from_config( lambda: cls._call_tool_once(manifest, tool_name, normalized_arguments, origin=origin), ) + @classmethod + async def _enforce_pinned_manifest( + cls, config: Dict[str, Any], *, origin: Optional[McpActionOrigin] = None + ) -> None: + manifest = cls._normalize_manifest(config) + additional_fields = manifest.get("additionalFields", {}) + approved = normalize_mcp_tool_fingerprints(additional_fields.get(MCP_TOOL_FINGERPRINTS_FIELD)) + if not approved: + return + connector = cls.create_connector(manifest, origin=origin) + try: + await connector.connect() + if not connector.session: + raise ValueError("MCP server did not create a session.") + tools = await cls._list_tools_from_session(connector.session) + prompts, _ = await cls._list_prompts_from_session(connector.session) + current = build_mcp_tool_fingerprints(tools, prompts) + drift = compare_mcp_fingerprints(approved, current) + if drift.get("has_drift"): + persist_mcp_tool_drift(manifest, drift) + log_event( + "[MCP_PINNING] MCP tool manifest drift detected", + level=logging.WARNING, + extra={ + **cls._build_operation_log_context(manifest, "pinning_check"), + "manifest_hash": current.get("manifest_hash"), + "new_count": len(drift.get("new", [])), + "changed_count": len(drift.get("changed", [])), + "removed_count": len(drift.get("removed", [])), + "prompt_changed_count": len(drift.get("prompts_changed", [])), + }, + ) + raise McpRuntimeError( + "MCP tools changed after approval. Review and re-approve the action.", + category="tool_pinning", + operation="pinning_check", + retryable=False, + ) + finally: + await connector.close() + @classmethod def _find_configured_tool(cls, config: Dict[str, Any], tool_name: str) -> Optional[Dict[str, Any]]: """Return cached MCP tool metadata for a factory invocation.""" diff --git a/application/single_app/semantic_kernel_plugins/openapi_plugin.py b/application/single_app/semantic_kernel_plugins/openapi_plugin.py index 30c0e8de7..401e55d16 100644 --- a/application/single_app/semantic_kernel_plugins/openapi_plugin.py +++ b/application/single_app/semantic_kernel_plugins/openapi_plugin.py @@ -165,7 +165,8 @@ def __init__(self, auth: Optional[Dict[str, Any]] = None, manifest: Optional[Dict[str, Any]] = None, openapi_spec_path: Optional[str] = None, - openapi_spec_content: Optional[Dict[str, Any]] = None): + openapi_spec_content: Optional[Dict[str, Any]] = None, + allowed_operations: Optional[List[str]] = None): """ Initialize the OpenAPI plugin with user-provided configuration. @@ -175,6 +176,8 @@ def __init__(self, manifest: Additional manifest configuration openapi_spec_path: Path to the OpenAPI specification file (YAML or JSON) - DEPRECATED openapi_spec_content: OpenAPI specification content as parsed dict (preferred) + allowed_operations: Optional list of operation IDs/function names to expose. + Missing or empty lists allow every operation for backward compatibility. """ import logging logging.info(f"[OPEN_API_PLUGIN] Initializing plugin with base_url: {base_url}") @@ -189,6 +192,11 @@ def __init__(self, self.base_url = base_url.rstrip('/') # Remove trailing slash self.auth = auth or {} self.manifest = manifest or {} + self.allowed_operations = { + str(operation).strip() + for operation in (allowed_operations or []) + if str(operation).strip() + } # Track function calls for citations self.function_calls = [] @@ -209,6 +217,22 @@ def __init__(self, import traceback logging.error(f"[OPEN_API_PLUGIN] Traceback: {traceback.format_exc()}") raise + + def _operation_function_name(self, method: str, path: str, operation: Dict[str, Any]) -> str: + """Return the function name used for an OpenAPI operation.""" + operation_id = operation.get("operationId") + if operation_id: + return operation_id + return f"{method}_{path.replace('/', '_').replace('{', '').replace('}', '')}" + + def _is_operation_allowed(self, operation_id: str) -> bool: + """Return whether an operation is enabled for this plugin.""" + return not self.allowed_operations or operation_id in self.allowed_operations + + def _reject_disallowed_operation(self, operation_id: str) -> None: + """Raise a stable error when an operation is present but disabled.""" + if not self._is_operation_allowed(operation_id): + raise PermissionError(f"OpenAPI operation '{operation_id}' is not enabled for this action.") def _load_openapi_spec(self) -> Dict[str, Any]: """Load OpenAPI specification from content or file.""" @@ -298,7 +322,11 @@ def _generate_metadata(self) -> Dict[str, Any]: methods = [] for path, ops in paths.items(): for method, op in ops.items(): - op_id = op.get("operationId", f"{method}_{path.replace('/', '_')}") + if not isinstance(op, dict): + continue + op_id = self._operation_function_name(method, path, op) + if not self._is_operation_allowed(op_id): + continue description = op.get("description", "") parameters = [] # Path/query parameters - resolve $ref references first @@ -435,10 +463,10 @@ def _create_operation_functions(self): if not isinstance(operation, dict): continue - operation_id = operation.get("operationId") - if not operation_id: - # Generate operation ID if not provided - operation_id = f"{method}_{path.replace('/', '_').replace('{', '').replace('}', '')}" + operation_id = self._operation_function_name(method, path, operation) + if not self._is_operation_allowed(operation_id): + logging.info(f"[OPEN_API_PLUGIN] Skipping disabled operation: {operation_id}") + continue logging.info(f"[OPEN_API_PLUGIN] Creating function: {operation_id} for {method.upper()} {path}") @@ -805,6 +833,8 @@ def _call_api_operation(self, operation_id: str, path: str, method: str, operati import logging import datetime import time + + self._reject_disallowed_operation(operation_id) # Log the function call logging.info(f"[OPEN_API_PLUGIN] Calling operation: {operation_id} ({method.upper()} {path})") @@ -1292,19 +1322,28 @@ def call_operation(self, operation_id: str, **kwargs) -> Any: # Try exact match first for path, ops in self.openapi.get("paths", {}).items(): for method, op in ops.items(): - if op.get("operationId") == operation_id: + if not isinstance(op, dict): + continue + candidate_operation_id = self._operation_function_name(method, path, op) + if candidate_operation_id == operation_id: + self._reject_disallowed_operation(candidate_operation_id) operation_found = True operation_data = op operation_path = path operation_method = method + operation_id = candidate_operation_id break if operation_found: break # If not found, try common operation name variations if not operation_found: - available_ops = [op.get("operationId") for path_ops in self.openapi.get("paths", {}).values() - for op in path_ops.values() if op.get("operationId")] + available_ops = [ + self._operation_function_name(method, path, op) + for path, path_ops in self.openapi.get("paths", {}).items() + for method, op in path_ops.items() + if isinstance(op, dict) and self._is_operation_allowed(self._operation_function_name(method, path, op)) + ] # Try removing common prefixes/suffixes variations = [ @@ -1328,7 +1367,9 @@ def call_operation(self, operation_id: str, **kwargs) -> Any: # Find the matched operation for path, ops in self.openapi.get("paths", {}).items(): for method, op in ops.items(): - if op.get("operationId") == available_op: + if not isinstance(op, dict): + continue + if self._operation_function_name(method, path, op) == available_op: operation_found = True operation_data = op operation_path = path @@ -1343,8 +1384,12 @@ def call_operation(self, operation_id: str, **kwargs) -> Any: if not operation_found: error_msg = f"Operation '{operation_id}' not found in OpenAPI specification" logging.error(f"[OPEN_API_PLUGIN] {error_msg}") - available_ops = [op.get("operationId") for path_ops in self.openapi.get("paths", {}).values() - for op in path_ops.values() if op.get("operationId")] + available_ops = [ + self._operation_function_name(method, path, op) + for path, path_ops in self.openapi.get("paths", {}).items() + for method, op in path_ops.items() + if isinstance(op, dict) and self._is_operation_allowed(self._operation_function_name(method, path, op)) + ] logging.error(f"[OPEN_API_PLUGIN] Available operations: {available_ops}") raise ValueError(error_msg) diff --git a/application/single_app/semantic_kernel_plugins/openapi_plugin_factory.py b/application/single_app/semantic_kernel_plugins/openapi_plugin_factory.py index 28ddce8b8..089a17e3a 100644 --- a/application/single_app/semantic_kernel_plugins/openapi_plugin_factory.py +++ b/application/single_app/semantic_kernel_plugins/openapi_plugin_factory.py @@ -45,14 +45,24 @@ def create_from_config(cls, config: Dict[str, Any]) -> OpenApiPlugin: openapi_spec_content = config.get('openapi_spec_content') # Also check in additionalFields for compatibility + additional_fields = config.get('additionalFields', {}) if not openapi_spec_content and 'additionalFields' in config: - openapi_spec_content = config['additionalFields'].get('openapi_spec_content') + openapi_spec_content = additional_fields.get('openapi_spec_content') + + allowed_operations = config.get('allowed_operations') + if allowed_operations is None: + allowed_operations = additional_fields.get('allowed_operations') + if allowed_operations is None: + allowed_operations = [] + elif not isinstance(allowed_operations, list): + raise ValueError("allowed_operations must be a list of operation IDs") if openapi_spec_content: return OpenApiPlugin( base_url=base_url, auth=auth, - openapi_spec_content=openapi_spec_content + openapi_spec_content=openapi_spec_content, + allowed_operations=allowed_operations, ) # Fall back to legacy file-based approach for backward compatibility @@ -71,7 +81,8 @@ def create_from_config(cls, config: Dict[str, Any]) -> OpenApiPlugin: return OpenApiPlugin( base_url=base_url, auth=auth, - openapi_spec_path=openapi_spec_path + openapi_spec_path=openapi_spec_path, + allowed_operations=allowed_operations, ) @classmethod diff --git a/application/single_app/static/json/schemas/document_search_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/document_search_plugin.additional_settings.schema.json index a8c8a3384..8b8431878 100644 --- a/application/single_app/static/json/schemas/document_search_plugin.additional_settings.schema.json +++ b/application/single_app/static/json/schemas/document_search_plugin.additional_settings.schema.json @@ -13,6 +13,37 @@ ], "default": "all" }, + "allowed_scopes": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "personal", + "group", + "public" + ] + }, + "uniqueItems": true, + "default": [ + "personal", + "group", + "public" + ] + }, + "allowed_group_ids": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "allowed_public_workspace_ids": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, "default_top_n": { "type": "integer", "minimum": 1, diff --git a/application/single_app/static/json/schemas/mcp_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/mcp_plugin.additional_settings.schema.json index f6b244d39..2cf84996c 100644 --- a/application/single_app/static/json/schemas/mcp_plugin.additional_settings.schema.json +++ b/application/single_app/static/json/schemas/mcp_plugin.additional_settings.schema.json @@ -140,6 +140,44 @@ "additionalProperties": true }, "description": "Cached MCP tool metadata from discovery." + }, + "mcp_prompts": { + "type": "array", + "default": [], + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "description": {"type": "string"}, + "arguments": {"type": "array"} + }, + "required": ["name"], + "additionalProperties": true + }, + "description": "Cached MCP prompt metadata from discovery." + }, + "mcp_tool_fingerprints": { + "type": "object", + "properties": { + "manifest_hash": {"type": "string"}, + "tools": { + "type": "object", + "additionalProperties": {"type": "string"} + }, + "prompts": { + "type": "object", + "additionalProperties": {"type": "string"} + }, + "discovered_at": {"type": "string"} + }, + "required": ["manifest_hash", "tools", "prompts", "discovered_at"], + "additionalProperties": true, + "description": "Approved MCP tool and prompt fingerprints captured at discovery time." + }, + "mcp_tool_drift": { + "type": ["object", "null"], + "additionalProperties": true, + "description": "Server-recorded MCP tool or prompt drift metadata that requires review." } }, "required": ["server_profile", "transport", "auth_method"], diff --git a/application/single_app/static/json/schemas/openapi_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/openapi_plugin.additional_settings.schema.json new file mode 100644 index 000000000..dea957a96 --- /dev/null +++ b/application/single_app/static/json/schemas/openapi_plugin.additional_settings.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "OpenAPI plugin additional settings", + "description": "Settings that control OpenAPI specification import, base URL overrides, and operation exposure.", + "type": "object", + "additionalProperties": true, + "properties": { + "base_url": { + "type": "string", + "description": "Resolved API base URL used by the connector." + }, + "base_url_override": { + "type": "boolean", + "description": "Whether the action uses a manually overridden base URL instead of the specification server." + }, + "allowed_operations": { + "type": "array", + "description": "Optional list of operation IDs or generated operation keys exposed to agents. Missing or empty means all operations are available.", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "openapi_spec_content": { + "type": "object", + "description": "Validated OpenAPI specification content." + }, + "openapi_source_type": { + "type": "string", + "description": "How the specification content is stored.", + "enum": ["content", "file", "path"] + } + } +} diff --git a/application/v2_ui/src/components/workspaceActions/ActionAdvancedFields.tsx b/application/v2_ui/src/components/workspaceActions/ActionAdvancedFields.tsx index a81757e0a..66623d8a0 100644 --- a/application/v2_ui/src/components/workspaceActions/ActionAdvancedFields.tsx +++ b/application/v2_ui/src/components/workspaceActions/ActionAdvancedFields.tsx @@ -1,10 +1,10 @@ // ActionAdvancedFields.tsx -import { useId } from 'react'; +import { useId, useState } from 'react'; import { EditorDependents, EditorPanelFieldset, EditorRow, EditorSwitch } from '../workspace/EditorLayout'; import { ActionField, ActionJsonInput, ACTION_INPUT_CLASS } from './ActionFields'; import { ActionSchemaFields } from './ActionSchemaFields'; -import { isRecord, type ActionTypeDefinition } from '../../lib/workspaceAuthoring'; +import { editorName, isRecord, type ActionTypeDefinition } from '../../lib/workspaceAuthoring'; import { actionFieldError, actionHasStoredArraySecrets, actionText, actionValueAt, changeActionField } from '../../lib/workspaceActionLogic'; import type { ActionEditorHints } from '../../lib/workspaceActionServices'; import type { ActionConnectorProps } from '../../lib/workspaceActionTypes'; @@ -18,6 +18,7 @@ export function ActionAdvancedFields(props: ActionConnectorProps & { }) { const { draft, onChange, readOnly, definition, hints, hintsError } = props; const id = useId(); + const [editMachineName, setEditMachineName] = useState(false); const rawReminder = actionValueAt(draft, reminderPath); const reminder = isRecord(rawReminder) ? rawReminder : {}; const enabled = reminder.enabled === true; @@ -28,11 +29,17 @@ export function ActionAdvancedFields(props: ActionConnectorProps & {
{draft.id || 'Assigned when the action is saved.'}
diff --git a/application/v2_ui/src/components/workspaceActions/ActionAuthentication.tsx b/application/v2_ui/src/components/workspaceActions/ActionAuthentication.tsx index 5296ddc2e..0809e5b22 100644 --- a/application/v2_ui/src/components/workspaceActions/ActionAuthentication.tsx +++ b/application/v2_ui/src/components/workspaceActions/ActionAuthentication.tsx @@ -1,6 +1,8 @@ // ActionAuthentication.tsx import { useId } from 'react'; +import { EditorDependents, EditorPanelFieldset, EditorSwitch } from '../workspace/EditorLayout'; +import { ActionConnectionCheck } from './ActionConnectionCheck'; import { ActionField, ActionSecretInput, ACTION_INPUT_CLASS } from './ActionFields'; import { actionAuthMethod, actionAuthModes, actionFieldError, actionText, actionValueAt, @@ -20,6 +22,8 @@ export function ActionAuthentication(props: ActionConnectorProps & { definition: const identityTypes = native.identityTypes ?? ['api_key', 'bearer_token', 'client_secret', 'connection_string', 'managed_identity', 'username_password']; const identities = props.identities.filter((identity) => identityTypes.includes(identity.auth_type)); const identity = identities.find((candidate) => candidate.id === draft.identity_id); + const proxyIdentities = props.identities.filter((candidate) => candidate.auth_type === 'username_password'); + const proxyIdentityId = actionText(actionValueAt(draft, '/additionalFields/basic_auth_identity_id')); const identityMissing = Boolean(draft.identity_id && !identity); const groupScoped = Boolean(props.groupScope); // A group member cannot list identities (403): the list is unresolvable, so a bound identity is @@ -41,6 +45,33 @@ export function ActionAuthentication(props: ActionConnectorProps & { definition: disabled={readOnly} error={actionFieldError(props.errors, path)} onChange={(value) => onChange((current) => changeActionField(current, path, value))} /> ); + const yamcsProxyAuth = draft.type === 'yamcs' ? ( +@@ -151,10 +182,12 @@ export function ActionAuthentication(props: ActionConnectorProps & { definition: ? textField('/additionalFields/pat_name', 'Personal access token name', 'The reusable identity contains the PAT secret; its token name is configured on this action.', true) : null} {draft.identity_id && draft.type === 'snowflake' && method === 'key_pair' ? secretField('/additionalFields/private_key_passphrase', 'Private key passphrase', 'Only needed for an encrypted private key.') : null} + {yamcsProxyAuth}
Stored secrets are never returned to this form. Keep their masked state, enter a replacement, or choose Clear. Changing another field does not replace credentials or erase hidden configuration.
+The blob service endpoint is derived automatically when saving or testing. A stored connection string keeps the saved endpoint.
@@ -129,16 +173,6 @@ export function ActionConfigurationFields(props: ActionConnectorProps & { defini : null}{feedback.message}
+ {stale ?This result describes an earlier draft. Run the command again to check your current configuration.
: null} + {feedback.errors.length ?Warnings
+{request.busy}
: null} +Provided actions are read-only. Validation and connection testing are disabled.
: null} + {!readOnly && configurationError ?Resolve the highlighted configuration errors before validating or connecting.
: null} +{label}
+{description}
+No workspaces are available from the current bootstrap data.
+ )} +Current custom value: {parsed.custom}
+Use reset to switch this action to page-based target lengths.
+ ++ Search and summarize documents the current user is allowed to read. These settings can narrow + the action, but they never grant access beyond the user’s workspace permissions. +
+Connect to a Model Context Protocol server and expose its tools or prompts to agents. Server discovery and connection testing are explicit commands; selecting a template or saving an action never runs either.
-Connect to one Model Context Protocol server. Choose either a ready-to-use server with a fixed endpoint, a vendor compatibility profile where you provide the endpoint, or a fully custom server.
+Loading MCP catalogues…
: null} {catalogue.presetsError || catalogue.preconfigurationsError ?{catalogue.presetsError} The built-in generic preset remains available.
: null} - {catalogue.preconfigurationsError ?{catalogue.preconfigurationsError} This is not an empty catalogue; your saved selection is retained.
: null} + {catalogue.presetsError ?{catalogue.presetsError} The built-in custom-server profile remains available.
: null} + {catalogue.preconfigurationsError ?{catalogue.preconfigurationsError} Saved selections are retained.
: null}- Saved MCP preconfigurations aren’t available for group actions yet. Choose a compatibility preset below or configure the server manually. -
- ) : (<> -No tool allowlist is configured. All server tools may be exposed when Load tools is enabled.
: null}Discovery connects and lists tools; it does not invoke them.
+Discovery connects, lists tools and prompts, and records approved fingerprints. It does not invoke tools.
{pinningError}
: null} + {approvedFingerprints ?Approved manifest: {approvedFingerprints.manifest_hash.slice(0, 12)} · discovered {approvedFingerprints.discovered_at}
: null} + {Object.keys(drift).length ?Tools changed — review required.
+ {['new', 'changed', 'removed', 'prompts_new', 'prompts_changed', 'prompts_removed'].map((key) => connectorStrings(drift[key]).length ?{key.replaceAll('_', ' ')}: {connectorStrings(drift[key]).join(', ')}
: null)} +The endpoint or transport changed after this discovery. Run discovery again to verify the current server.
: null} + {discoveredChanges?.hasChanges ?Discovery differs from the previously approved manifest. Saving will approve this new manifest.
+ {discoveredChanges.new.length ?New tools: {discoveredChanges.new.join(', ')}
: null} + {discoveredChanges.changed.length ?Changed tools: {discoveredChanges.changed.join(', ')}
: null} + {discoveredChanges.removed.length ?Removed tools: {discoveredChanges.removed.join(', ')}
: null} + {discoveredChanges.promptsNew.length ?New prompts: {discoveredChanges.promptsNew.join(', ')}
: null} + {discoveredChanges.promptsChanged.length ?Changed prompts: {discoveredChanges.promptsChanged.join(', ')}
: null} + {discoveredChanges.promptsRemoved.length ?Removed prompts: {discoveredChanges.promptsRemoved.join(', ')}
: null} +{busy}
: null} -Provided actions are read-only. Discovery and connection testing are disabled.
: null} - {!readOnly && Object.keys(localErrors).length ?Resolve the highlighted configuration errors before validating or connecting.
: null} -{Object.keys(headers).length}/20 headers. Values may contain up to 4096 characters and must not contain line breaks.
{feedback.message}
- {stale ?This result describes an earlier draft. Run the command again to check your current configuration.
: null} - {feedback.errors.length ?Warnings
-Last imported: {source.filename}
: null} ++ Selected specification: {source.filename} +
:No OpenAPI file selected.
}For a specification hosted at a URL, download the JSON or YAML file, then upload it here. SimpleChat does not fetch remote specifications. @@ -253,15 +232,40 @@ export function OpenApiActionConfiguration(props: ActionConnectorProps) {
Using the specification server: {specBaseUrl}
: null} + {!baseUrlOverride && !specBaseUrl ?The specification does not declare a usable server URL. Turn on Override base URL to enter one before saving.
: null} +{filteredOperations.length} matching operations. This is a read-only description, not an operation runner.
++ {enabledOperationCount} of {operationKeys.length} operations enabled. Empty saved allow-lists mean all operations for backward compatibility, so saving is blocked if you disable every operation. +
+{localErrors['additionalFields.allowed_operations']}
: null} +{filteredOperations.length} matching operations. Enable switches control which functions agents can see and call.
Operation ID: {operation.id || 'Generated by the connector'}
+ +Operation key: {operation.key}{operation.id ? null : ' (generated by the connector)'}
{operation.description ?{operation.description}
: null} {operation.tags.length ?Tags: {operation.tags.join(', ')}
: null} {operation.parameters.length ?{specJson}
: null}
- {busy}
: null} -Provided actions are read-only. Import, validation, and connection testing are disabled.
: null} -Legacy authentication analysis is retained in additional fields.
: null} + {Object.values(configurationErrors).length ?{Object.values(configurationErrors).join(' ')}
: null} +The name, description, ID, and metadata are kept. Review configuration before saving.
diff --git a/docs/explanation/features/ACTION_CONFIGURATION_PANE_CLEANUP.md b/docs/explanation/features/ACTION_CONFIGURATION_PANE_CLEANUP.md
new file mode 100644
index 000000000..035e25f2f
--- /dev/null
+++ b/docs/explanation/features/ACTION_CONFIGURATION_PANE_CLEANUP.md
@@ -0,0 +1,74 @@
+# Action Configuration Pane Cleanup
+
+## Overview
+
+Version implemented: **0.261.276**
+
+The V2 action editor now presents action settings by purpose instead of showing every stored key as a generic configuration field. Owners see identity, supported configuration controls, authentication, validation, and advanced JSON in clearer places, which reduces accidental edits to internal state and makes security-sensitive checks happen after credentials are entered.
+
+## Dependencies
+
+- V2 workspace action editor and action catalogue APIs.
+- Existing action governance for personal, group, and global actions.
+- Microsoft 365 cloud metadata for the read-only Graph endpoint.
+- MCP discovery and notification infrastructure.
+- OpenAPI parsing and operation registration.
+
+## Technical specifications
+
+### Catalogue and picker cleanup
+
+The plugin catalogue de-duplicates entries by action type before it reaches the V2 picker. Legacy or internal-only types (`databricks_table`, `embedding_model`, `queue_storage`, `ui_test`, and `sql_schema`) are hidden from new-action creation while existing records continue to load through their current editor paths.
+
+`sql_query` now displays as **SQL Database**. The stored type remains unchanged for compatibility, and schema discovery is included automatically when agents need table context.
+
+### Editor layout
+
+Custom field creation was removed from the V2 action editor. Unknown values are preserved in the draft and remain reviewable in **Advanced → JSON**, but schema-covered parent paths are not rendered again as removable fields. Empty Configuration sections are hidden, so types such as Azure Maps show only the sections they actually need.
+
+Connection checks for native actions, MCP, and OpenAPI now live in **Authentication**. Type switching starts from the selected type's defaults and warns that current settings for the action type will be cleared.
+
+### Per-action changes
+
+- Azure Maps keeps the subscription key and connection check in Authentication.
+- Blob Storage shows Markdown read/upload file types as ordinary switches.
+- Document Search stores allowed scopes and optional group/public workspace allow-lists, enforces them at runtime, and normalizes page-based summary targets while preserving legacy custom values.
+- Microsoft 365 Calendar, Email, OneDrive, and SharePoint use native V2 definitions with capability switches and a read-only Graph endpoint derived from the deployment cloud.
+- Log Analytics hides the internal query-history cache from Configuration.
+- MCP combines preconfigured servers and compatibility presets into one server template picker, stores approved tool and prompt fingerprints, filters unapproved drift at runtime, and surfaces drift badges and notifications.
+- OpenAPI shows the selected spec filename, supports a read-only spec-derived base URL with an override switch, and stores per-operation enablement in `additionalFields.allowed_operations`.
+- Yamcs reverse proxy Basic authentication moved into Authentication under its own switch.
+
+## Usage instructions
+
+Create or edit an action from My Workspace, a group workspace, or Admin Settings global actions. Configure only the fields shown for the selected type. Use **Authentication** for credentials and connection testing, and use **Advanced → JSON** for inspection of preserved legacy values rather than adding new custom fields.
+
+For MCP actions, run **Discover Tools** after endpoint, transport, auth, prompt loading, or allowed tool selections change. Save only after reviewing the approved tool list. If a **Tools changed — review** badge appears later, open the action, review drift, and rediscover to approve the current server manifest.
+
+For OpenAPI actions, upload or replace the spec, review the spec-derived base URL, enable only the operations the agent should call, and test from Authentication.
+
+## Testing and validation
+
+Functional coverage added in version **0.261.276**:
+
+- `functional_tests/test_action_catalogue_dedupe_hidden_types.py`
+- `functional_tests/test_action_editor_configuration_cleanup_static.py`
+- `functional_tests/test_document_search_action_scope_enforcement.py`
+- `functional_tests/test_mcp_tool_fingerprint_pinning.py`
+- `functional_tests/test_openapi_allowed_operations_filter.py`
+
+Documentation validation uses:
+
+- `python .\scripts\build_docs_inventory.py`
+- `python .\functional_tests\test_docs_app_surface_coverage.py`
+- `python .\functional_tests\test_docs_site_quality.py`
+
+## Known limitations
+
+MCP drift persistence is best effort. Runtime drift metadata and owner notifications are written when the action origin can be resolved and backing storage is available; failures are logged and the unsafe changed tools remain blocked. Existing MCP actions without approved fingerprints run as unpinned legacy actions until they are saved with a connection-affecting change, when discovery and approval become mandatory.
+
+The `ui_test` action type is hidden from new-action creation but has no standalone reference page because it is not a user-facing action. Existing records are still preserved by the editor path.
+
+OpenAPI `allowed_operations` is backward compatible: a missing or empty list means every operation from the spec remains enabled.
+
+Document Search still cannot broaden a user's personal, group, or public workspace permissions. Action scope settings only narrow what the current user can already access.
diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md
index efe903071..963e7ab52 100644
--- a/docs/explanation/release_notes.md
+++ b/docs/explanation/release_notes.md
@@ -4,6 +4,12 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver
### **(v0.261.279)**
+#### Bug Fixes
+
+* **MCP Fingerprint Import Cycle**
+ * Shared MCP fingerprint metadata normalization now lives in a dependency-neutral module, avoiding cyclic imports during action loading and notification delivery.
+ * (Ref: `functions_mcp_fingerprint_metadata.py`, `functions_mcp_operations.py`, `functions_mcp_tool_pinning.py`)
+
#### New Features
* **Feedback and Safety Review in V2**
@@ -33,6 +39,24 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver
#### New Features
+* **Action Configuration Pane Cleanup**
+ * The V2 action editor now hides empty configuration sections, removes ad hoc custom fields, keeps unknown legacy values in Advanced JSON, and moves connection checks into Authentication where credentials are visible.
+ * Action-specific configuration now reflects the actual supported settings: Document Search scopes and summary targets, Microsoft 365 capability switches and read-only Graph endpoint, OpenAPI spec-derived base URL and operation switches, MCP server templates and tool fingerprint approval, and Yamcs reverse proxy authentication.
+ * (Ref: `ActionEditorPage.tsx`, `ActionConfigurationFields.tsx`, `ActionAuthentication.tsx`, `DocumentSearchActionConfiguration.tsx`, `McpActionConfiguration.tsx`, `OpenApiActionConfiguration.tsx`, [Action Configuration Pane Cleanup](features/ACTION_CONFIGURATION_PANE_CLEANUP.md))
+
+#### Bug Fixes
+
+* **Action Catalogue Deduplication and Legacy Type Hiding**
+ * Databricks no longer appears multiple times in the new-action picker, SQL actions display as SQL Database with schema discovery included, and legacy/internal-only types are hidden from new-action creation while existing actions remain editable.
+ * (Ref: `route_backend_plugins.py`, `functions_workspace_authoring.py`, action reference docs)
+
+#### User Interface Enhancements
+
+* **Safer Action Validation and Review Badges**
+ * MCP actions now require discovery approval before saving new or changed server manifests, runtime calls block changed tools, and Actions/Agents lists flag drift with a "Tools changed — review" badge.
+ * OpenAPI actions show the chosen spec file name, offer explicit operation enablement, and reject disabled operations before an HTTP request is sent.
+ * (Ref: `functions_mcp_tool_pinning.py`, `mcp_plugin_factory.py`, `openapi_plugin.py`, `ActionsSection.tsx`, `AgentsSection.tsx`)
+
* **V2 Admin Help Group Matches the Classic Page**
* **Support** now offers the Menu Name, the Send Feedback destination and its Support Recipient Email, nested under the Support menu switch. The card reads **Needs configuration** while Send Feedback is on with no recipient, and the empty recipient field is marked **Required**. A malformed address is refused beside the field instead of being cleared, and turning the menu on no longer switches Send Feedback off.
* **Send Feedback** has an Overview and the **Report a Bug** and **Request a Feature** forms. They prefill your name and email, check each field, record the submission, and open a text-only email draft to the SimpleChat team, with a link to open it again. The overview explains how this differs from your users' own Send Feedback and links to the Support settings.
@@ -41,12 +65,6 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver
* The **Registered** / **Unregistered** release notifications badge now sits beside the version in V2 and opens the registration dialog.
* (Ref: `admin_settings_fields.py`, `functions_support_latest_features.py`, `GET /api/v2/admin/latest-features`, `AdminSettingsPage.tsx`, `SendFeedback.tsx`, `LatestFeaturesVisibility.tsx`, `AdminLatestFeatures.tsx`, `ReleaseNotificationsBadge.tsx`, [V2 Admin Help Settings](features/V2_ADMIN_HELP_SETTINGS.md))
-#### Bug Fixes
-
-* **Application Title No Longer Doubles in Latest Features**
- * An application title containing "SimpleChat", such as "SimpleChat Contoso", was substituted twice in Latest Features copy and showed up as "SimpleChat Contoso Contoso". The title is now applied once, on both the classic pages and V2.
- * (Ref: `support_menu_config.py`, `test_v2_admin_latest_features_api.py`)
-
### **(v0.261.275)**
#### New Features
diff --git a/docs/reference/actions/azure-maps-openlayers.md b/docs/reference/actions/azure-maps-openlayers.md
index 08ca86958..0fe8e39ec 100644
--- a/docs/reference/actions/azure-maps-openlayers.md
+++ b/docs/reference/actions/azure-maps-openlayers.md
@@ -23,12 +23,17 @@ Use it when an agent needs to turn known locations, areas, or paths into a map i
- Azure Maps subscription key; key auth; agents enabled with `enable_semantic_kernel`.
- Users also need access to the action through workspace or governance policy where applicable.
+
## Configuration overview
-Use common setup, then provide **Subscription Key** and test the Azure Maps connection.
+Azure Maps now has no separate Configuration section in the V2 action editor because the action only needs credentials. Enter the subscription key in **Authentication**, then run the connection check from the same section after the credential is present.
Shared wizard steps: [Common action setup steps](../#common-action-setup-steps).
+## V2 editor cleanup
+
+The V2 editor no longer offers ad hoc custom fields for this action. Use **Advanced → JSON** only when reviewing preserved legacy values that are not part of the supported Azure Maps setup.
+
## What people see in chat
The map appears under the agent's reply, in personal conversations and in group conversations
diff --git a/docs/reference/actions/blob-storage.md b/docs/reference/actions/blob-storage.md
index 4927aa7d9..922874038 100644
--- a/docs/reference/actions/blob-storage.md
+++ b/docs/reference/actions/blob-storage.md
@@ -25,6 +25,7 @@ Use it when an agent should work with a controlled container path without broad
- Agents enabled with `enable_semantic_kernel`.
- Users also need access to the action through workspace or governance policy where applicable.
+
## Configuration overview
Choose **Authentication Type**:
@@ -33,7 +34,9 @@ Choose **Authentication Type**:
- **Managed Identity** uses the application's Azure identity with the **Blob Service Endpoint**. Choose it when you want to avoid stored secrets and can grant the app identity the required Storage Blob data role ahead of time.
- **Account Key** uses the **Blob Service Endpoint** plus a primary or secondary storage account key. Choose it when a full connection string is not desired but key-based access is still required.
-Then set **Container Name**, optional **Blob Prefix**, default capabilities, and supported read/upload file types. The endpoint must be an Azure Blob service hostname such as `https://account.blob.core.windows.net`; SimpleChat validates the endpoint before saving and again before building the action client.
+Then set **Container Name**, optional **Blob Prefix**, default capabilities, and the supported Markdown read/upload file type switches. Those file-type groups are schema-defined settings, so V2 shows them as ordinary switches instead of removable unknown fields. The endpoint must be an Azure Blob service hostname such as `https://account.blob.core.windows.net`; SimpleChat validates the endpoint before saving and again before building the action client.
+
+Connection testing appears in **Authentication**, after the credential choice. The V2 editor no longer offers ad hoc custom fields; preserved legacy values remain available in **Advanced → JSON** for review.
Shared wizard steps: [Common action setup steps](../#common-action-setup-steps).
diff --git a/docs/reference/actions/databricks-table.md b/docs/reference/actions/databricks-table.md
index 1998a4722..eb6fd614f 100644
--- a/docs/reference/actions/databricks-table.md
+++ b/docs/reference/actions/databricks-table.md
@@ -23,9 +23,12 @@ Use only to keep older manifests working. For new work, use Databricks.
- Existing legacy manifest with key auth; the create-action UI hides `databricks_table`.
- Users also need access to the action through workspace or governance policy where applicable.
+
## Configuration overview
-No new-action panel; migrate to [Databricks]({{ '/reference/actions/databricks/' | relative_url }}) for new actions.
+`databricks_table` is hidden from new-action creation. Existing actions continue to load so owners can review, edit, or migrate them, but new governed Databricks work should use [Databricks]({{ '/reference/actions/databricks/' | relative_url }}).
+
+Connection testing, when available for an editable migrated configuration, appears in **Authentication**. The V2 editor no longer offers ad hoc custom fields; preserved legacy values remain available in **Advanced → JSON**.
Shared wizard steps: [Common action setup steps](../#common-action-setup-steps).
diff --git a/docs/reference/actions/databricks.md b/docs/reference/actions/databricks.md
index 030d2eaa9..35f7b2fd3 100644
--- a/docs/reference/actions/databricks.md
+++ b/docs/reference/actions/databricks.md
@@ -35,7 +35,7 @@ Use Databricks when governed analytics data lives in Databricks and users should
5. Choose **Authentication Method**: **Personal Access Token**, bearer token, **Service Principal**, or **Managed Identity**.
6. Fill **Token** or **Client ID**, **Client Secret**, and **Tenant ID** when those auth modes are selected, or choose **Reusable Identity**.
7. Set **Max Rows**, **Timeout (seconds)**, and **Wait Timeout (seconds)**.
-8. Use **Test Connection**.
+8. Use the connection check in **Authentication**.
## Example prompts
@@ -51,9 +51,15 @@ Use Databricks when governed analytics data lives in Databricks and users should
| Authentication fails | Token, service principal, or managed identity lacks workspace/warehouse access. | Grant Databricks SQL permissions and retest. |
| Long queries fail | Timeout or wait timeout is too low. | Tune the SQL or raise **Timeout** and **Wait Timeout** within limits. |
+
+## V2 catalogue behavior
+
+The action catalogue now de-duplicates Databricks entries by action type, so new actions show one Databricks SQL Warehouse connector. The legacy `databricks_table` type is hidden from new-action creation but existing actions of that type remain editable for review and migration.
+
+Connection testing appears in **Authentication**, after the selected Databricks credential. The V2 editor no longer offers ad hoc custom fields; preserved legacy values remain available in **Advanced → JSON**.
+
## Related
- [SQL Query](../sql-query/)
- [Snowflake](../snowflake/)
- [Actions reference index]({{ '/reference/actions/' | relative_url }})
-
diff --git a/docs/reference/actions/document-search.md b/docs/reference/actions/document-search.md
index d7a4324e2..1303a43aa 100644
--- a/docs/reference/actions/document-search.md
+++ b/docs/reference/actions/document-search.md
@@ -23,9 +23,26 @@ Use it when an agent should reason over workspace documents as a tool. Use the n
- Accessible personal, group, or public workspace content; no external credentials.
- Users also need access to the action through workspace or governance policy where applicable.
+
## Configuration overview
-Configure default scope, result limit, preferred windowing, and summary defaults.
+Document Search can be narrowed without granting the agent any access the user does not already have. Choose the allowed scopes:
+
+- **My workspace** for the signed-in user's personal documents.
+- **Group workspaces** for groups the signed-in user can access. Choose all accessible groups or restrict the action to selected group IDs.
+- **Public workspaces** for public workspaces the signed-in user can access. Choose all accessible public workspaces or restrict the action to selected public workspace IDs.
+
+At runtime, SimpleChat intersects those action settings with the current user's group and public workspace access. A disabled scope is rejected even if the user asks for it explicitly.
+
+Set the default result limit with the slider or number input. The UI slider covers 5–100 results for normal setup, and the number input accepts values up to the existing backend maximum of 500 for specialized actions.
+
+## Summary and windowing controls
+
+The window unit controls how much source material goes into each summarization pass. Use **Automatic** when SimpleChat should choose the amount, **Fixed size** when a predictable number of pages or chunks is needed, or **Percent of document** when long documents should be sampled proportionally. Only the selected sizing value is stored; unused values are cleared to avoid conflicting instructions.
+
+Focus instructions tell the summarizer what to prioritize, such as contract dates, safety incidents, or implementation steps. Window summary and final summary target lengths are stored as page targets from the V2 sliders. Legacy free-text values, such as word counts, are preserved as custom values and can be reset to page-based targets.
+
+The V2 editor no longer offers ad hoc custom fields. Use **Advanced → JSON** only to review preserved legacy values.
Shared wizard steps: [Common action setup steps](../#common-action-setup-steps).
diff --git a/docs/reference/actions/embedding-model.md b/docs/reference/actions/embedding-model.md
index e4ee79038..71d5915be 100644
--- a/docs/reference/actions/embedding-model.md
+++ b/docs/reference/actions/embedding-model.md
@@ -28,12 +28,12 @@ Use it for workflows that need vector representations, not for conversational an
does not require storing an API key.
- Users also need access to the action through workspace or governance policy where applicable.
+
## Configuration overview
-Configure existing/default embedding action settings through Agents controls.
-The default action follows the administrator's global selection. Explicitly
-configured action manifests retain their own endpoint contract; they are not
-silently redirected to the application's document-search model.
+`embedding_model` is hidden from new-action creation because the default embedding action is managed through the global AI Connections embedding selection and governance. Existing explicit manifests remain editable for compatibility.
+
+The V2 editor no longer offers ad hoc custom fields; preserved legacy values remain available in **Advanced → JSON**.
Shared wizard steps: [Common action setup steps](../#common-action-setup-steps).
diff --git a/docs/reference/actions/index.md b/docs/reference/actions/index.md
index 1c1030e1b..0944916b5 100644
--- a/docs/reference/actions/index.md
+++ b/docs/reference/actions/index.md
@@ -16,7 +16,7 @@ Actions are approved tools that agents can call. They may query data, search doc
2. Fill **Display Name**, review generated **Name**, and add a useful **Description**.
3. Complete action-specific **Configuration** fields described on each action page.
4. In **Advanced**, optionally add **Metadata (JSON)**, **Additional Fields (JSON)**, or Key Vault expiration reminder metadata when secret tracking is enabled.
-5. Review **Action Summary**, use **Test Connection** where provided, then save the action.
+5. Review **Action Summary**, use the connection check in **Authentication** where provided, then save the action.
If an action type does not appear, check Agents settings, workspace action permissions, and Governance policy.
@@ -28,7 +28,7 @@ If an action type does not appear, check Agents settings, workspace action permi
| [Databricks Table](./databricks-table/) | Compatibility wrapper for legacy `databricks_table` manifests. | Use only to keep older manifests working. For new work, use Databricks. | overview |
| [Document Search](./document-search/) | Searches accessible SimpleChat documents, retrieves chunks, and summarizes documents using current user access. | Use it when an agent should reason over workspace documents as a tool. Use the normal grounded-search panel for one-off user searches. | overview |
| [Log Analytics](./log-analytics/) | Discovers Azure Log Analytics table schemas and runs read-only KQL queries against one workspace. | Use it for operational telemetry and App Insights-style investigation. Use SQL/Snowflake/Databricks for business data. | overview |
-| [SQL Query](./sql-query/) | Executes SQL queries and scalar queries against configured SQL Server, Azure SQL, PostgreSQL, MySQL, or SQLite databases. | Exact relational data questions with joins, filters, counts, or current rows. | full guide |
+| [SQL Database](./sql-query/) | Executes SQL queries and scalar queries against configured SQL Server, Azure SQL, PostgreSQL, MySQL, or SQLite databases. | Exact relational data questions with joins, filters, counts, or current rows. | full guide |
| [SQL Schema](./sql-schema/) | Discovers tables, table schemas, and relationships for configured SQL databases. | Schema discovery before an agent writes SQL. | full guide |
| [Databricks]({{ '/reference/actions/databricks/' | relative_url }}) | Runs read-only SQL and discovers catalogs, schemas, tables, and table details through Databricks SQL Statement Execution. | Governed analytics queries in Azure Commercial Databricks. | full guide |
| [Snowflake](./snowflake/) | Runs read-only Snowflake SQL and discovers databases, schemas, tables, and table details. | Governed analytics queries in Snowflake. | full guide |
diff --git a/docs/reference/actions/log-analytics.md b/docs/reference/actions/log-analytics.md
index de850e269..e0d2d394a 100644
--- a/docs/reference/actions/log-analytics.md
+++ b/docs/reference/actions/log-analytics.md
@@ -23,9 +23,12 @@ Use it for operational telemetry and App Insights-style investigation. Use SQL/S
- Workspace ID plus identity, user, service principal, or key access.
- Users also need access to the action through workspace or governance policy where applicable.
+
## Configuration overview
-Set Workspace ID, Cloud, optional endpoints, Authentication Method, and test the connection.
+Set Workspace ID, Cloud, optional endpoints, and Authentication Method. The internal `query_history` cache is not shown as a configurable field because it is runtime state, not an owner-controlled setting.
+
+Connection testing appears in **Authentication**, after credentials. The V2 editor no longer offers ad hoc custom fields; preserved legacy values remain available in **Advanced → JSON**.
Shared wizard steps: [Common action setup steps](../#common-action-setup-steps).
diff --git a/docs/reference/actions/m365-calendar.md b/docs/reference/actions/m365-calendar.md
index a83ec783c..c505e4c04 100644
--- a/docs/reference/actions/m365-calendar.md
+++ b/docs/reference/actions/m365-calendar.md
@@ -127,6 +127,15 @@ Automatic invitation mode retains its immediate behavior, without a second
Send button. Older unbound pending invitations remain visible and cancellable,
but need to be prepared again for safe confirmation.
+
+## V2 action configuration
+
+The V2 editor registers this as a native Microsoft 365 action instead of relying on the legacy Microsoft Graph action. Capability switches come from the action catalogue and are stored under `additionalFields.m365_capabilities`, so owners can expose only the calendar, email, file, or site operations this action needs.
+
+The Microsoft Graph endpoint is read-only and derived from the deployment cloud: commercial deployments use `graph.microsoft.com`, and US Government deployments use `graph.microsoft.us`. Custom cloud endpoint behavior follows the deployment's configured Microsoft 365 runtime instead of being edited per action.
+
+Connection testing appears in **Authentication**, after the delegated or workflow identity choice. The V2 editor no longer offers ad hoc custom fields; preserved legacy values remain available in **Advanced → JSON**.
+
## Related
- [Microsoft 365 data and approvals]({{ '/guides/microsoft-365-conversation-data/' | relative_url }})
diff --git a/docs/reference/actions/m365-email.md b/docs/reference/actions/m365-email.md
index 8cd9c80d9..6e81cda74 100644
--- a/docs/reference/actions/m365-email.md
+++ b/docs/reference/actions/m365-email.md
@@ -129,6 +129,15 @@ Automatic send mode and **Mark message as read** keep their immediate behavior
and do not create an extra confirmation. A legacy pending action without enough
stored authorization context must be cancelled and prepared again.
+
+## V2 action configuration
+
+The V2 editor registers this as a native Microsoft 365 action instead of relying on the legacy Microsoft Graph action. Capability switches come from the action catalogue and are stored under `additionalFields.m365_capabilities`, so owners can expose only the calendar, email, file, or site operations this action needs.
+
+The Microsoft Graph endpoint is read-only and derived from the deployment cloud: commercial deployments use `graph.microsoft.com`, and US Government deployments use `graph.microsoft.us`. Custom cloud endpoint behavior follows the deployment's configured Microsoft 365 runtime instead of being edited per action.
+
+Connection testing appears in **Authentication**, after the delegated or workflow identity choice. The V2 editor no longer offers ad hoc custom fields; preserved legacy values remain available in **Advanced → JSON**.
+
## Related
- [Microsoft 365 data and approvals]({{ '/guides/microsoft-365-conversation-data/' | relative_url }})
diff --git a/docs/reference/actions/m365-onedrive.md b/docs/reference/actions/m365-onedrive.md
index 6f3074332..4fc1b195e 100644
--- a/docs/reference/actions/m365-onedrive.md
+++ b/docs/reference/actions/m365-onedrive.md
@@ -44,6 +44,15 @@ still require the requesting user's delegated access.
Evidence is labeled with source and capture information and follows conversation
retention. Disconnecting Microsoft 365 does not retract an already-published copy.
+
+## V2 action configuration
+
+The V2 editor registers this as a native Microsoft 365 action instead of relying on the legacy Microsoft Graph action. Capability switches come from the action catalogue and are stored under `additionalFields.m365_capabilities`, so owners can expose only the calendar, email, file, or site operations this action needs.
+
+The Microsoft Graph endpoint is read-only and derived from the deployment cloud: commercial deployments use `graph.microsoft.com`, and US Government deployments use `graph.microsoft.us`. Custom cloud endpoint behavior follows the deployment's configured Microsoft 365 runtime instead of being edited per action.
+
+Connection testing appears in **Authentication**, after the delegated or workflow identity choice. The V2 editor no longer offers ad hoc custom fields; preserved legacy values remain available in **Advanced → JSON**.
+
## Related
- [Microsoft 365 data and approvals]({{ '/guides/microsoft-365-conversation-data/' | relative_url }})
diff --git a/docs/reference/actions/m365-sharepoint.md b/docs/reference/actions/m365-sharepoint.md
index 25ecf82a0..24c5e6a5c 100644
--- a/docs/reference/actions/m365-sharepoint.md
+++ b/docs/reference/actions/m365-sharepoint.md
@@ -52,6 +52,15 @@ SharePoint permission. Revoking source access stops new reads but does not
delete those published copies. Sharing a previously private conversation also
requires acknowledgement of this disclosure.
+
+## V2 action configuration
+
+The V2 editor registers this as a native Microsoft 365 action instead of relying on the legacy Microsoft Graph action. Capability switches come from the action catalogue and are stored under `additionalFields.m365_capabilities`, so owners can expose only the calendar, email, file, or site operations this action needs.
+
+The Microsoft Graph endpoint is read-only and derived from the deployment cloud: commercial deployments use `graph.microsoft.com`, and US Government deployments use `graph.microsoft.us`. Custom cloud endpoint behavior follows the deployment's configured Microsoft 365 runtime instead of being edited per action.
+
+Connection testing appears in **Authentication**, after the delegated or workflow identity choice. The V2 editor no longer offers ad hoc custom fields; preserved legacy values remain available in **Advanced → JSON**.
+
## Related
- [Microsoft 365 data and approvals]({{ '/guides/microsoft-365-conversation-data/' | relative_url }})
diff --git a/docs/reference/actions/mcp.md b/docs/reference/actions/mcp.md
index 83edfe083..5f1ecf6cd 100644
--- a/docs/reference/actions/mcp.md
+++ b/docs/reference/actions/mcp.md
@@ -28,16 +28,30 @@ Use MCP when a provider already exposes an MCP server and agents should use thos
- Tool exposure policy: load tools, optional prompts, allowed tool names, and large-result handling.
- Agents/actions enabled with [`enable_semantic_kernel`]({{ '/admin/agents-actions/' | relative_url }}).
+
## Configure the action
1. Choose **MCP**.
-2. Optionally select **Preconfigured MCP Server** and **Server Preset**.
-3. Choose **Transport** and fill **Endpoint** with the remote server URL.
+2. Select a **Server template** if one fits. Ready-to-use servers, such as Microsoft Learn, supply the endpoint and imply the compatibility profile. Vendor compatibility profiles, such as Splunk, apply transport and compatibility defaults while you provide the endpoint.
+3. Choose **Transport** and fill **Endpoint** when the selected template does not fix it.
4. Choose **Authentication Method** and fill the shown auth fields or **Custom Headers (JSON)**.
5. Set **Load tools**, **Load prompts**, **Validate tool arguments**, and **Large Result Policy**.
-6. Fill **Allowed Tool Names** to expose only approved tools, or leave blank to expose all discovered tools.
-7. Use **Discover Tools** to populate **Discovered Tool Metadata (JSON)**.
-8. Set **Request Timeout**, **Connect Timeout**, **SSE Read Timeout**, **Retry Count**, and **Retry Backoff**, then use **Test Connection**.
+6. Use **Discover Tools** against the current endpoint, transport, and auth before saving. Discovery records tool metadata, prompt metadata, and SHA-256 fingerprints for approval.
+7. Review the discovered tools, then choose which approved tools to expose. Re-run discovery after endpoint, transport, authentication, prompt loading, or allowed tool names change.
+8. Set **Request Timeout**, **Connect Timeout**, **SSE Read Timeout**, **Retry Count**, and **Retry Backoff**.
+9. Use **Validate and test** in **Authentication**.
+
+The V2 editor no longer offers ad hoc custom fields. Use **Advanced → JSON** only to review preserved legacy values.
+
+## Tool fingerprint pinning
+
+Saving a new or materially changed MCP action is blocked until discovery has approved the current server manifest. SimpleChat computes a stable SHA-256 fingerprint from each tool's original name, description, input schema, output schema, and annotations, and from each prompt's name, description, and arguments. The approved manifest is stored in `additionalFields.mcp_tool_fingerprints` with the approved tool list.
+
+At runtime, pinned actions compare the live server manifest with the approved fingerprints before tools are registered. Tools whose hashes still match can run. New, changed, or removed tools and prompts are treated as drift; new or changed tools are not registered until an owner reviews the diff and rediscovers the server.
+
+The Actions and Agents lists show a **Tools changed — review** badge when stored drift metadata exists. The editor summarizes new, changed, removed, and prompt drift so owners can decide whether to re-approve by rediscovery.
+
+Existing MCP actions that have no approved fingerprints are treated as unpinned legacy actions. They can continue to run until they are saved with a connection-affecting change, at which point discovery and approval are required.
## Retired stdio actions
@@ -64,6 +78,8 @@ Environment-enforced destination restrictions are a minimum that Admin Settings
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Discovery finds too many tools | **Allowed Tool Names** is blank. | Add one approved tool name per line. |
+| Saving is blocked | Discovery has not approved the current endpoint, transport, auth, tools, and prompts. | Run **Discover Tools**, review the metadata, then save. |
+| Tools changed badge appears | The live server manifest no longer matches the approved fingerprints. | Open the action, review drift, rediscover tools, and save if the changes are trusted. |
| Tool calls fail on arguments | Arguments do not match the discovered schema. | Enable **Validate tool arguments** and rediscover tools. |
| SSE connections time out | Read timeout is too low or a proxy interrupts the stream. | Increase **SSE Read Timeout** and verify the network path. |
| An existing action is marked unsupported | It uses the retired stdio transport. | Explicitly configure a supported remote transport and endpoint, or delete the action. Admin/global scope does not restore stdio support. |
diff --git a/docs/reference/actions/openapi.md b/docs/reference/actions/openapi.md
index 866974d86..1e813ff74 100644
--- a/docs/reference/actions/openapi.md
+++ b/docs/reference/actions/openapi.md
@@ -18,23 +18,30 @@ OpenAPI turns a YAML or JSON OpenAPI specification into callable operations. The
Use OpenAPI when an HTTP API has a maintained OpenAPI spec and users need agents to call multiple documented operations. Do not use it for arbitrary web pages or PDFs; use Smart HTTP. Do not use it for MCP servers; use MCP. Tool-call quality depends on clear operation IDs and parameter schemas.
+
## Before you start
- An OpenAPI specification file in YAML or JSON, max 5 MB according to the modal help.
-- API **Base URL**, auto-populated from the spec when available but editable.
+- A server URL in the spec's `servers` list. V2 populates the API base URL from that spec value and keeps it read-only unless **Override base URL** is enabled.
- Authentication details: no auth, API key, bearer token, basic auth, OAuth2 access token, or compatible reusable identity.
+- A decision about which operations to expose. An empty allow-list keeps the backward-compatible behavior where all operations are enabled.
- Agents/actions enabled with [`enable_semantic_kernel`]({{ '/admin/agents-actions/' | relative_url }}).
## Configure the action
1. Choose **OpenAPI**.
-2. Upload **OpenAPI Specification File**.
-3. Review or edit **Base URL**.
+2. Upload **OpenAPI Specification File**. The V2 picker shows the selected or imported file name and a **Replace file** button instead of the browser's native empty file text.
+3. Review **API base URL** from the spec. Turn on **Override base URL** only when the deployed API is hosted somewhere else.
4. Read the **API Information** panel after parsing.
-5. Choose **Authentication Type**: **No Authentication**, **API Key**, bearer token, **Basic Auth**, or **OAuth2**.
-6. For API key auth, choose **Location**, fill **Key Name**, and fill **API Key**.
-7. For bearer, basic, or OAuth2 auth, fill the shown token, username/password, or access token fields.
-8. Use **Test Connection**.
+5. Enable only the operations the agent should call, or use **Enable all** and **Disable all** to manage the list quickly. Selections are stored in `additionalFields.allowed_operations`; an empty list means every operation is enabled for compatibility.
+6. Choose **Authentication Type**: **No Authentication**, **API Key**, bearer token, **Basic Auth**, or **OAuth2**.
+7. For API key auth, choose **Location**, fill **Key Name**, and fill **API Key**.
+8. For bearer, basic, or OAuth2 auth, fill the shown token, username/password, or access token fields.
+9. Use **Validate and test** in **Authentication**.
+
+OpenAPI runtime registration and direct calls both enforce `allowed_operations`, so a disabled operation is rejected before an HTTP request is sent.
+
+The V2 editor no longer offers ad hoc custom fields. Use **Advanced → JSON** only to review preserved legacy values.
## Example prompts
@@ -48,6 +55,7 @@ Use OpenAPI when an HTTP API has a maintained OpenAPI spec and users need agents
| --- | --- | --- |
| Operation names are hard for the agent to choose | The spec has missing or ambiguous operation IDs. | Improve operation IDs and summaries, then re-upload. |
| Authentication fails | Wrong auth type, header name, token, or query parameter is configured. | Match modal auth fields to the API security scheme. |
+| A needed operation is missing | The operation switch is off or **Disable all** was used. | Re-enable the operation and save. |
| Spec upload fails | File is invalid JSON/YAML or too large. | Validate the spec and keep it under the documented upload limit. |
## Related
diff --git a/docs/reference/actions/queue-storage.md b/docs/reference/actions/queue-storage.md
index 089658607..08a4508a3 100644
--- a/docs/reference/actions/queue-storage.md
+++ b/docs/reference/actions/queue-storage.md
@@ -23,9 +23,12 @@ Use it when an agent should enqueue work for another system. Do not use it for l
- Queue endpoint and identity or key auth; the create-action UI hides `queue_storage`.
- Users also need access to the action through workspace or governance policy where applicable.
+
## Configuration overview
-Use legacy/existing queue manifest fields where exposed.
+`queue_storage` is hidden from new-action creation because it is a legacy/internal integration. Existing queue manifests continue to load for review or migration where governance permits.
+
+The V2 editor no longer offers ad hoc custom fields; preserved legacy values remain available in **Advanced → JSON**.
Shared wizard steps: [Common action setup steps](../#common-action-setup-steps).
diff --git a/docs/reference/actions/sql-query.md b/docs/reference/actions/sql-query.md
index 8877388c4..efac50e58 100644
--- a/docs/reference/actions/sql-query.md
+++ b/docs/reference/actions/sql-query.md
@@ -1,7 +1,7 @@
---
layout: page
-title: "SQL Query"
-description: "Full guide for the SQL Query SimpleChat action."
+title: "SQL Database"
+description: "Full guide for the SQL Database SimpleChat action."
section: "Reference"
audience: user
---
@@ -12,11 +12,11 @@ audience: user
## What this action does
-SQL Query executes SQL against configured SQL Server, Azure SQL, PostgreSQL, MySQL, or SQLite databases. It can validate a query, run row-returning queries, run scalar queries, and answer database questions with supplied SQL. **Max Rows** and **Timeout (seconds)** limit result size and execution time.
+SQL Database executes SQL against configured SQL Server, Azure SQL, PostgreSQL, MySQL, or SQLite databases. It can validate a query, run row-returning queries, run scalar queries, and answer database questions with supplied SQL. **Max Rows** and **Timeout (seconds)** limit result size and execution time.
## Why and when to use it
-Use SQL Query when the answer depends on exact relational data: joins, filters, counts, current rows, or scalar values. Pair it with SQL Schema when the agent needs table and column discovery before writing SQL. Do not use it for Snowflake or Databricks warehouses; those have dedicated actions. Do not use it for spreadsheets; use Tabular Processing. Keep **Read Only Mode** set to **Yes (Recommended)** unless writes have been separately reviewed.
+Use SQL Database when the answer depends on exact relational data: joins, filters, counts, current rows, or scalar values. Schema discovery is included automatically for agents that need table context before writing SQL. Do not use it for Snowflake or Databricks warehouses; those have dedicated actions. Do not use it for spreadsheets; use Tabular Processing. Keep **Read Only Mode** set to **Yes (Recommended)** unless writes have been separately reviewed.
## Before you start
@@ -27,14 +27,14 @@ Use SQL Query when the answer depends on exact relational data: joins, filters,
## Configure the action
-1. Choose **SQL Query**. If the combined SQL panel opens, select **Plugin Type** > **Query Plugin**.
+1. Choose **SQL Database**. Existing manifests still use the `sql_query` type internally, and the combined SQL panel selects **Query Plugin** for row-returning queries.
2. Choose **Database Type**: **SQL Server**, **Azure SQL**, **PostgreSQL**, **MySQL**, or **SQLite**.
3. Choose **Connection Method**: **Connection String** or **Individual Parameters**.
4. Fill **Connection String**, or fill **Server**, **Database**, optional **Port**, and **ODBC Driver** when shown.
5. Choose **Authentication Type** and fill the shown credential fields, or choose **Reusable Identity** when available.
6. Under **Query Plugin Settings**, leave **Read Only Mode** at **Yes (Recommended)** unless writes are approved.
7. Set **Max Rows** and **Timeout (seconds)**.
-8. Use **Test Connection**, then save.
+8. Use the connection check in **Authentication**, then save.
## Example prompts
@@ -50,9 +50,15 @@ Use SQL Query when the answer depends on exact relational data: joins, filters,
| The agent writes invalid SQL | It does not know the schema. | Pair the agent with SQL Schema or provide table and column names in instructions. |
| Results are truncated | **Max Rows** is lower than the result size. | Ask for a grouped or filtered result, or raise **Max Rows** within governance limits. |
+
+## V2 naming and schema discovery
+
+The V2 action picker labels this action **SQL Database** so owners can distinguish it from warehouse-specific actions. The stored type remains `sql_query` for compatibility. Schema discovery is included automatically for agents that need table context, so the separate `sql_schema` creation card is hidden while existing `sql_schema` actions remain editable.
+
+Connection testing appears in **Authentication**, after credentials. The V2 editor no longer offers ad hoc custom fields; preserved legacy values remain available in **Advanced → JSON**.
+
## Related
- [SQL Schema](../sql-schema/)
- [Actions reference index]({{ '/reference/actions/' | relative_url }})
- [Agents administration]({{ '/admin/agents-actions/' | relative_url }})
-
diff --git a/docs/reference/actions/sql-schema.md b/docs/reference/actions/sql-schema.md
index 7620a3811..e09c82ce4 100644
--- a/docs/reference/actions/sql-schema.md
+++ b/docs/reference/actions/sql-schema.md
@@ -25,15 +25,22 @@ Use SQL Schema when an agent must understand table names, column names, types, a
- A decision about **Include System Tables**; the recommended default is **No**.
- Agents/actions enabled with [`enable_semantic_kernel`]({{ '/admin/agents-actions/' | relative_url }}).
+
## Configure the action
-1. Choose the SQL action flow and select **Plugin Type** > **Schema Plugin**.
+`sql_schema` is hidden from new-action creation because the SQL Database action now includes schema discovery for agents that need table context. Existing SQL Schema actions continue to load and can be edited for compatibility, but new actions should use [SQL Database]({{ '/reference/actions/sql-query/' | relative_url }}).
+
+For existing actions:
+
+1. Open the existing SQL Schema action.
2. Choose **Database Type** and **Connection Method**.
3. Fill **Connection String** or individual **Server**, **Database**, **Port**, and **ODBC Driver** fields.
4. Choose **Authentication Type** and provide credentials or a **Reusable Identity**.
-5. Under **Schema Plugin Settings**, keep **Include System Tables** set to **No (Recommended)** unless system metadata is required.
+5. Keep **Include System Tables** set to **No (Recommended)** unless system metadata is required.
6. Use **Table Filter** to narrow discovery, for example `user_*` or `*_log`.
-7. Use **Test Connection** before saving.
+7. Use the connection check in **Authentication** before saving.
+
+The V2 editor no longer offers ad hoc custom fields; preserved legacy values remain available in **Advanced → JSON**.
## Example prompts
@@ -47,7 +54,7 @@ Use SQL Schema when an agent must understand table names, column names, types, a
| --- | --- | --- |
| Schema output includes too much noise | System tables or broad schemas are included. | Turn off **Include System Tables** and add a **Table Filter**. |
| The agent cannot see relationships | Metadata permissions are missing or relationships are not defined as foreign keys. | Grant metadata visibility or document the relationship in agent instructions. |
-| The action type is not visible as a separate card | `sql_schema` is hidden from the create-action UI. | Use the combined SQL configuration panel and choose **Schema Plugin**. |
+| The action type is not visible as a separate card | `sql_schema` is hidden from the create-action UI. | Use SQL Database for new actions; existing records still open for compatibility. |
## Related
diff --git a/docs/reference/actions/yamcs.md b/docs/reference/actions/yamcs.md
index b243423b3..6601534a5 100644
--- a/docs/reference/actions/yamcs.md
+++ b/docs/reference/actions/yamcs.md
@@ -24,9 +24,14 @@ Use it for mission-control visibility. Do not use it for commanding; the plugin
- If the server sits behind a reverse proxy that challenges callers, the username and password that proxy expects.
- Users also need access to the action through workspace or governance policy where applicable.
+
## Configuration overview
-Set Server URL, Instance, Processor, Authentication Method, credentials, Max Rows, Timeout, Verify TLS, and optional read-only archive SQL.
+Set Server URL, Instance, Processor, primary Yamcs Authentication Method, credentials, Max Rows, Timeout, Verify TLS, and optional read-only archive SQL.
+
+Reverse proxy credentials now live in **Authentication** under **Reverse proxy authentication**, not in Configuration. Turn that switch on only when a proxy in front of Yamcs challenges SimpleChat before the request reaches Yamcs. The proxy username, password, or reusable identity fields appear there when needed.
+
+Connection testing appears in **Authentication**, after both Yamcs and optional proxy credentials. The V2 editor no longer offers ad hoc custom fields; preserved legacy values remain available in **Advanced → JSON**.
Shared wizard steps: [Common action setup steps](../#common-action-setup-steps).
@@ -57,7 +62,7 @@ unsupported:
| Access Token | No | The bearer token also needs `Authorization`. |
Choosing a blocked combination is reported when you save the action and when you use
-**Test Yamcs Connection**, so the conflict surfaces before an agent depends on it.
+the Authentication connection check, so the conflict surfaces before an agent depends on it.
### Supplying the proxy credential
diff --git a/functional_tests/test_action_catalogue_dedupe_hidden_types.py b/functional_tests/test_action_catalogue_dedupe_hidden_types.py
new file mode 100644
index 000000000..b9abd3cf8
--- /dev/null
+++ b/functional_tests/test_action_catalogue_dedupe_hidden_types.py
@@ -0,0 +1,72 @@
+# test_action_catalogue_dedupe_hidden_types.py
+#!/usr/bin/env python3
+"""
+Functional test for action catalogue dedupe and hidden legacy types.
+Version: 0.261.276
+Implemented in: 0.261.276
+
+This test statically validates that get_plugin_types deduplicates by action type,
+hides legacy/internal-only types from new-action creation, and exposes the
+catalogue metadata used by the V2 action picker.
+"""
+
+import ast
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+ROUTE_BACKEND_PLUGINS_FILE = REPO_ROOT / "application" / "single_app" / "route_backend_plugins.py"
+WORKSPACE_AUTHORING_FILE = REPO_ROOT / "application" / "single_app" / "functions_workspace_authoring.py"
+
+
+def _module_tree(path: Path) -> ast.Module:
+ return ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+
+
+def _function_source(path: Path, name: str) -> str:
+ source = path.read_text(encoding="utf-8")
+ tree = ast.parse(source, filename=str(path))
+ function_node = next(
+ node for node in tree.body
+ if isinstance(node, ast.FunctionDef) and node.name == name
+ )
+ return ast.get_source_segment(source, function_node) or ""
+
+
+def test_hidden_new_action_types_are_declared():
+ source = ROUTE_BACKEND_PLUGINS_FILE.read_text(encoding="utf-8")
+ assert "DATABRICKS_LEGACY_TABLE_PLUGIN_TYPE" in source
+ for plugin_type in ("embedding_model", "queue_storage", "ui_test", "sql_schema"):
+ assert plugin_type in source, f"{plugin_type} must be marked hidden/legacy for new action creation"
+ assert "entry['hidden'] = True" in source
+ assert "entry['legacy'] = True" in source
+
+
+def test_get_plugin_types_dedupes_by_module_class_and_type_key():
+ function_source = _function_source(ROUTE_BACKEND_PLUGINS_FILE, "get_plugin_types")
+ assert "obj.__module__ == module.__name__" in function_source
+ assert "type_entries = {}" in function_source
+ assert "if module_type in type_entries" in function_source
+ assert "list(type_entries.values())" in function_source
+
+
+def test_catalogue_metadata_for_sql_databricks_and_m365():
+ function_source = _function_source(ROUTE_BACKEND_PLUGINS_FILE, "get_plugin_types")
+ assert "display_name = 'SQL Database'" in function_source
+ assert "Schema discovery is included automatically" in function_source
+ assert "Run governed read-only SQL Warehouse queries against Azure Databricks" in function_source
+ assert "graph_endpoint=_catalog_graph_endpoint()" in function_source
+
+
+def test_editor_types_preserve_catalogue_flags_and_capabilities():
+ function_source = _function_source(WORKSPACE_AUTHORING_FILE, "build_action_editor_types")
+ for field in ("hidden", "legacy", "capabilities", "defaults", "graph_endpoint"):
+ assert field in function_source, f"build_action_editor_types must pass through {field}"
+
+
+if __name__ == "__main__":
+ test_hidden_new_action_types_are_declared()
+ test_get_plugin_types_dedupes_by_module_class_and_type_key()
+ test_catalogue_metadata_for_sql_databricks_and_m365()
+ test_editor_types_preserve_catalogue_flags_and_capabilities()
+ print("Action catalogue cleanup checks passed.")
diff --git a/functional_tests/test_action_editor_configuration_cleanup_static.py b/functional_tests/test_action_editor_configuration_cleanup_static.py
new file mode 100644
index 000000000..4bf224ef6
--- /dev/null
+++ b/functional_tests/test_action_editor_configuration_cleanup_static.py
@@ -0,0 +1,65 @@
+# test_action_editor_configuration_cleanup_static.py
+#!/usr/bin/env python3
+"""
+Functional test for V2 action editor configuration cleanup.
+Version: 0.261.276
+Implemented in: 0.261.276
+
+This test statically validates that unknown schema fields are preserved for
+Advanced JSON instead of rendered as removable custom fields, connection checks
+live in Authentication, and internal Yamcs/Log Analytics settings are covered.
+"""
+
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+ACTION_SCHEMA_FIELDS = REPO_ROOT / "application" / "v2_ui" / "src" / "components" / "workspaceActions" / "ActionSchemaFields.tsx"
+ACTION_CONFIGURATION_FIELDS = REPO_ROOT / "application" / "v2_ui" / "src" / "components" / "workspaceActions" / "ActionConfigurationFields.tsx"
+ACTION_AUTHENTICATION = REPO_ROOT / "application" / "v2_ui" / "src" / "components" / "workspaceActions" / "ActionAuthentication.tsx"
+ACTION_CONNECTION_CHECK = REPO_ROOT / "application" / "v2_ui" / "src" / "components" / "workspaceActions" / "ActionConnectionCheck.tsx"
+ACTION_EDITOR_PAGE = REPO_ROOT / "application" / "v2_ui" / "src" / "pages" / "workspace" / "ActionEditorPage.tsx"
+
+
+def test_custom_schema_fields_are_not_rendered_or_removed():
+ source = ACTION_SCHEMA_FIELDS.read_text(encoding="utf-8")
+ assert "function ActionAddCustomField" not in source
+ assert "Remove field" not in source
+ assert "known.startsWith(`${pointer}/`)" in source
+
+
+def test_connection_check_moved_to_authentication():
+ configuration_source = ACTION_CONFIGURATION_FIELDS.read_text(encoding="utf-8")
+ authentication_source = ACTION_AUTHENTICATION.read_text(encoding="utf-8")
+ connection_source = ACTION_CONNECTION_CHECK.read_text(encoding="utf-8")
+ assert "Connection check" not in configuration_source
+ assert "