From eeb106e4b18a766e7278e221e842bbbc0ba1ad5e Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 7 Oct 2026 09:57:35 -0400 Subject: [PATCH 1/2] Overhaul action configuration panes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../single_app/functions_global_actions.py | 2 + .../single_app/functions_group_actions.py | 2 + .../single_app/functions_mcp_operations.py | 17 + .../single_app/functions_mcp_tool_pinning.py | 334 +++++++++++++ .../single_app/functions_notifications.py | 4 + .../single_app/functions_personal_actions.py | 2 + .../functions_workspace_authoring.py | 3 + .../single_app/route_backend_plugins.py | 123 +++-- .../document_search_plugin.py | 233 ++++++++- .../semantic_kernel_plugins/mcp_plugin.py | 22 +- .../mcp_plugin_factory.py | 86 ++++ .../semantic_kernel_plugins/openapi_plugin.py | 69 ++- .../openapi_plugin_factory.py | 17 +- ...rch_plugin.additional_settings.schema.json | 31 ++ ...mcp_plugin.additional_settings.schema.json | 38 ++ ...api_plugin.additional_settings.schema.json | 35 ++ .../workspaceActions/ActionAdvancedFields.tsx | 19 +- .../workspaceActions/ActionAuthentication.tsx | 33 ++ .../ActionConfigurationFields.tsx | 96 ++-- .../ActionConnectionCheck.tsx | 155 ++++++ .../workspaceActions/ActionSchemaFields.tsx | 54 +-- .../DocumentSearchActionConfiguration.tsx | 448 ++++++++++++++++++ .../McpActionConfiguration.tsx | 172 ++++--- .../OpenApiActionConfiguration.tsx | 277 ++++++----- .../src/lib/workspaceActionConnectors.ts | 121 ++++- .../v2_ui/src/lib/workspaceActionLogic.ts | 35 +- .../v2_ui/src/lib/workspaceActionRegistry.ts | 25 + .../v2_ui/src/lib/workspaceAuthoring.ts | 10 + .../src/pages/workspace/ActionEditorPage.tsx | 11 +- .../src/pages/workspace/ActionsSection.tsx | 3 + .../src/pages/workspace/AgentsSection.tsx | 16 +- .../ACTION_CONFIGURATION_PANE_CLEANUP.md | 74 +++ docs/explanation/release_notes.md | 22 + .../actions/azure-maps-openlayers.md | 7 +- docs/reference/actions/blob-storage.md | 5 +- docs/reference/actions/databricks-table.md | 5 +- docs/reference/actions/databricks.md | 10 +- docs/reference/actions/document-search.md | 19 +- docs/reference/actions/embedding-model.md | 8 +- docs/reference/actions/index.md | 4 +- docs/reference/actions/log-analytics.md | 5 +- docs/reference/actions/m365-calendar.md | 9 + docs/reference/actions/m365-email.md | 9 + docs/reference/actions/m365-onedrive.md | 9 + docs/reference/actions/m365-sharepoint.md | 9 + docs/reference/actions/mcp.md | 26 +- docs/reference/actions/openapi.md | 22 +- docs/reference/actions/queue-storage.md | 5 +- docs/reference/actions/sql-query.md | 20 +- docs/reference/actions/sql-schema.md | 15 +- docs/reference/actions/yamcs.md | 9 +- ...st_action_catalogue_dedupe_hidden_types.py | 72 +++ ...ion_editor_configuration_cleanup_static.py | 65 +++ ...ocument_search_action_scope_enforcement.py | 207 ++++++++ .../test_mcp_tool_fingerprint_pinning.py | 147 ++++++ .../test_openapi_allowed_operations_filter.py | 147 ++++++ 57 files changed, 3010 insertions(+), 415 deletions(-) create mode 100644 application/single_app/functions_mcp_tool_pinning.py create mode 100644 application/single_app/static/json/schemas/openapi_plugin.additional_settings.schema.json create mode 100644 application/v2_ui/src/components/workspaceActions/ActionConnectionCheck.tsx create mode 100644 application/v2_ui/src/components/workspaceActions/DocumentSearchActionConfiguration.tsx create mode 100644 docs/explanation/features/ACTION_CONFIGURATION_PANE_CLEANUP.md create mode 100644 functional_tests/test_action_catalogue_dedupe_hidden_types.py create mode 100644 functional_tests/test_action_editor_configuration_cleanup_static.py create mode 100644 functional_tests/test_document_search_action_scope_enforcement.py create mode 100644 functional_tests/test_mcp_tool_fingerprint_pinning.py create mode 100644 functional_tests/test_openapi_allowed_operations_filter.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 877179baf..e4efb1724 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.275" +VERSION = "0.261.276" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform 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_operations.py b/application/single_app/functions_mcp_operations.py index 6c00cee87..992fe8548 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_tool_pinning 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..b8828498c --- /dev/null +++ b/application/single_app/functions_mcp_tool_pinning.py @@ -0,0 +1,334 @@ +# 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 + + +MCP_TOOL_FINGERPRINTS_FIELD = "mcp_tool_fingerprints" +MCP_TOOL_DRIFT_FIELD = "mcp_tool_drift" +MCP_PROMPTS_FIELD = "mcp_prompts" +MCP_DRIFT_NOTIFICATION_TYPE = "mcp_tool_drift_detected" +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 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_metadata(value: Any) -> List[Dict[str, Any]]: + from functions_mcp_operations import normalize_mcp_tool_metadata + + return normalize_mcp_tool_metadata(value) + + +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 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 {} + + +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"}}, + } + + # Lazy import: functions_mcp_operations imports this module. + from functions_mcp_operations import normalize_mcp_additional_fields + + 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 functions_notifications import create_notification + + notification_kwargs = { + "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.", + "metadata": { + "action_id": origin.action_id, + "scope_type": origin.scope_type, + "scope_id": origin.scope_id, + "manifest_hash": manifest_hash, + }, + "idempotency_key": f"mcp-tool-drift:{origin.scope_type}:{origin.scope_id}:{origin.action_id}:{manifest_hash}", + } + if origin.scope_type == "personal": + notification_kwargs["user_id"] = origin.scope_id + elif origin.scope_type == "group": + notification_kwargs["group_id"] = origin.scope_id + else: + notification_kwargs["assignment"] = {"roles": ["Admin", "ControlCenterAdmin"]} + create_notification(**notification_kwargs) + 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 & {
- onChange((current) => ({ ...current, name: event.target.value }))} /> +
+ onChange((current) => ({ ...current, name: event.target.value }))} /> + {!readOnly ? : null} +

{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' ? ( + + onChange((current) => changeActionField(current, '/additionalFields/enable_basic_auth', value))} /> + {actionValueAt(draft, '/additionalFields/enable_basic_auth') === true ? + {textField('/additionalFields/basic_auth_username', 'Proxy username', 'Username sent to the reverse proxy. A selected reusable identity can supply this value at runtime.', !proxyIdentityId)} + + + + {!proxyIdentityId ? secretField('/additionalFields/basic_auth_password', 'Proxy password', 'Password sent to the reverse proxy. Stored in Key Vault.') : null} + : null} + + ) : null; if (native.internal) return (

@@ -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.

+
); } diff --git a/application/v2_ui/src/components/workspaceActions/ActionConfigurationFields.tsx b/application/v2_ui/src/components/workspaceActions/ActionConfigurationFields.tsx index f1681145c..0d28f4bbb 100644 --- a/application/v2_ui/src/components/workspaceActions/ActionConfigurationFields.tsx +++ b/application/v2_ui/src/components/workspaceActions/ActionConfigurationFields.tsx @@ -1,22 +1,19 @@ // ActionConfigurationFields.tsx import { useId, useState } from 'react'; -import { FlaskConical } from 'lucide-react'; -import { GlassButton } from '../ui/primitives'; import { ConfirmDialog } from '../ui/ConfirmDialog'; -import { EditorFieldset, EditorPanel, EditorSwitch, type EditorFieldWidth } from '../workspace/EditorLayout'; +import { EditorFieldset, EditorSwitch, type EditorFieldWidth } from '../workspace/EditorLayout'; import { ActionField, ActionJsonInput, ActionLinesInput, ActionSecretInput, ACTION_INPUT_CLASS } from './ActionFields'; import { ActionSchemaFields } from './ActionSchemaFields'; import { CallAgentActionConfiguration } from './CallAgentActionConfiguration'; -import { ConnectorFeedbackPanel, useConnectorRequest } from './OpenApiActionConfiguration'; +import { DocumentSearchActionConfiguration } from './DocumentSearchActionConfiguration'; import { actionFieldError, actionHasStoredArraySecrets, actionText, actionValueAt, changeActionField, changeSqlConnectionMethod, deriveBlobEndpoint, displayedActionValue, } from '../../lib/workspaceActionLogic'; import { nativeActionDefinition, sqlConnectionMethod, usesDirectBlobConnectionString } from '../../lib/workspaceActionRegistry'; -import { EDITOR_SECRET_MASK, isRecord, type ActionTypeDefinition } from '../../lib/workspaceAuthoring'; -import { testWorkspaceAction } from '../../lib/workspaceActionServices'; -import { connectorTestScope, type ActionConnectorProps, type ActionFieldDescriptor } from '../../lib/workspaceActionTypes'; +import { EDITOR_SECRET_MASK, isRecord, pointerPart, type ActionTypeDefinition } from '../../lib/workspaceAuthoring'; +import type { ActionCapability, ActionConnectorProps, ActionFieldDescriptor } from '../../lib/workspaceActionTypes'; export function ActionDescriptorField({ props, descriptor }: { props: ActionConnectorProps; descriptor: ActionFieldDescriptor }) { const id = useId(); @@ -64,30 +61,73 @@ export function ActionDescriptorField({ props, descriptor }: { props: ActionConn ); } +function capabilityDefinition(definition: ActionTypeDefinition, native = nativeActionDefinition(definition.type)) { + if (native.capabilities) return native.capabilities; + if (!definition.capabilities?.length) return undefined; + const defaults = isRecord(definition.defaults) ? definition.defaults : {}; + const savedDefaults = isRecord(defaults.m365_capabilities) ? defaults.m365_capabilities : {}; + const options: ActionCapability[] = definition.capabilities.map((capability) => ({ + key: capability.key, + label: capability.label, + description: capability.description, + defaultEnabled: typeof savedDefaults[capability.key] === 'boolean' ? savedDefaults[capability.key] === true : capability.default !== false, + })); + return { path: '/additionalFields/m365_capabilities', options }; +} + +function configurationCoveredPaths(definition: ActionTypeDefinition) { + const native = nativeActionDefinition(definition.type); + const sql = ['sql_query', 'sql_schema'].includes(definition.type); + const capabilities = capabilityDefinition(definition, native); + return [ + ...native.fields.map(({ path }) => path), + '/additionalFields/identity_auth_type', + ...(sql ? ['/additionalFields/auth_type', '/additionalFields/username', '/additionalFields/password', '/additionalFields/identity_uses_connection_string'] : []), + ...(['databricks', 'databricks_table', 'snowflake', 'tableau', 'yamcs'].includes(definition.type) ? ['/additionalFields/auth_method'] : []), + ...(['databricks', 'databricks_table'].includes(definition.type) ? ['/additionalFields/workspace_url'] : []), + ...(['tableau', 'yamcs'].includes(definition.type) ? ['/additionalFields/server_url'] : []), + ...(definition.type === 'tableau' ? ['/additionalFields/pat_name'] : []), + ...(definition.type === 'snowflake' ? ['/additionalFields/private_key_passphrase'] : []), + ...(definition.type === 'rocksdb' ? ['/additionalFields/auth_scheme', '/additionalFields/api_key_header', '/additionalFields/base_url'] : []), + ...(definition.type === 'log_analytics' ? ['/additionalFields/query_history'] : []), + ...(definition.type === 'yamcs' ? [ + '/additionalFields/enable_basic_auth', '/additionalFields/basic_auth_username', + '/additionalFields/basic_auth_password', '/additionalFields/basic_auth_identity_id', + ] : []), + ...(capabilities ? [capabilities.path] : []), + ]; +} + +function schemaHasVisibleProperties(definition: ActionTypeDefinition) { + const coveredPaths = configurationCoveredPaths(definition); + const properties = definition.additional_fields_schema.properties ?? {}; + return Object.keys(properties).some((key) => { + const pointer = `/additionalFields/${pointerPart(key)}`; + return !coveredPaths.some((known) => + pointer === known || pointer.startsWith(`${known}/`) || known.startsWith(`${pointer}/`)); + }); +} + +export function hasActionConfigurationFields(definition: ActionTypeDefinition) { + if (definition.type === 'agent' || definition.type === 'document_search' || definition.type === 'search' || + definition.type === 'openapi' || definition.type === 'mcp') return true; + const native = nativeActionDefinition(definition.type); + return native.fields.length > 0 || Boolean(capabilityDefinition(definition, native)) || schemaHasVisibleProperties(definition); +} + export function ActionConfigurationFields(props: ActionConnectorProps & { definition: ActionTypeDefinition }) { const { draft, onChange, readOnly, definition } = props; const id = useId(); const [confirmParameters, setConfirmParameters] = useState(false); const native = nativeActionDefinition(draft.type); - const request = useConnectorRequest(props); if (draft.type === 'agent') return ; + if (draft.type === 'document_search' || draft.type === 'search') return ; const sql = ['sql_query', 'sql_schema'].includes(draft.type); - const capabilities = native.capabilities; + const capabilities = capabilityDefinition(definition, native); const rawCapabilities = capabilities ? actionValueAt(draft, capabilities.path) : undefined; const capabilityValues = isRecord(rawCapabilities) ? rawCapabilities : {}; const derivedEndpoint = draft.type === 'blob_storage' ? deriveBlobEndpoint(actionText(draft.auth.key)) : ''; - const coveredPaths = [ - ...native.fields.map(({ path }) => path), - '/additionalFields/identity_auth_type', - ...(sql ? ['/additionalFields/auth_type', '/additionalFields/username', '/additionalFields/password', '/additionalFields/identity_uses_connection_string'] : []), - ...(['databricks', 'databricks_table', 'snowflake', 'tableau', 'yamcs'].includes(draft.type) ? ['/additionalFields/auth_method'] : []), - ...(['databricks', 'databricks_table'].includes(draft.type) ? ['/additionalFields/workspace_url'] : []), - ...(['tableau', 'yamcs'].includes(draft.type) ? ['/additionalFields/server_url'] : []), - ...(draft.type === 'tableau' ? ['/additionalFields/pat_name'] : []), - ...(draft.type === 'snowflake' ? ['/additionalFields/private_key_passphrase'] : []), - ...(draft.type === 'rocksdb' ? ['/additionalFields/auth_scheme', '/additionalFields/api_key_header', '/additionalFields/base_url'] : []), - ...(capabilities ? [capabilities.path] : []), - ]; + const coveredPaths = configurationCoveredPaths(definition); return (
@@ -105,6 +145,10 @@ export function ActionConfigurationFields(props: ActionConnectorProps & { defini : null} {native.fields.map((descriptor) => )} + {draft.type.startsWith('m365_') && definition.graph_endpoint ? + + : null} {usesDirectBlobConnectionString(draft) ? (

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} - {native.testPath ? -
- void request.run('Testing connection…', (signal) => testWorkspaceAction(draft, props.original, definition, signal, connectorTestScope(props)))}> - {request.busy || 'Test connection'} - -
- -
: null} {confirmParameters ? setConfirmParameters(false)} diff --git a/application/v2_ui/src/components/workspaceActions/ActionConnectionCheck.tsx b/application/v2_ui/src/components/workspaceActions/ActionConnectionCheck.tsx new file mode 100644 index 000000000..c495b18b5 --- /dev/null +++ b/application/v2_ui/src/components/workspaceActions/ActionConnectionCheck.tsx @@ -0,0 +1,155 @@ +// ActionConnectionCheck.tsx + +import { useEffect, useRef, useState } from 'react'; +import { FlaskConical } from 'lucide-react'; +import { GlassButton } from '../ui/primitives'; +import { EditorPanel } from '../workspace/EditorLayout'; +import { nativeActionDefinition } from '../../lib/workspaceActionRegistry'; +import { testWorkspaceAction } from '../../lib/workspaceActionServices'; +import { connectorFeedback, testApiConnector, validateApiConnector, type ConnectorFeedback } from '../../lib/workspaceActionConnectors'; +import type { ActionConfiguration, ActionTypeDefinition } from '../../lib/workspaceAuthoring'; +import { connectorTestScope, type ActionConnectorProps } from '../../lib/workspaceActionTypes'; + +export function useConnectorRequest(props: ActionConnectorProps) { + const latest = useRef(props); + latest.current = props; + const activeRequest = useRef<{ + controller: AbortController; + draft: ActionConfiguration; + original: ActionConnectorProps['original']; + } | null>(null); + const [busy, setBusy] = useState(null); + const [feedback, setFeedback] = useState<{ value: ConnectorFeedback; draft: ActionConfiguration } | null>(null); + + useEffect(() => { + const active = activeRequest.current; + if (active && (active.draft !== props.draft || active.original !== props.original || props.readOnly || props.original?.read_only)) { + active.controller.abort(); + activeRequest.current = null; + setBusy(null); + } + }, [props.draft, props.original, props.readOnly]); + useEffect(() => () => { + activeRequest.current?.controller.abort(); + activeRequest.current = null; + }, []); + + async function run( + label: string, + operation: (signal: AbortSignal) => Promise, + apply?: (draft: ActionConfiguration, result: T) => ActionConfiguration, + describe?: (result: T) => ConnectorFeedback, + ): Promise { + const snapshot = latest.current; + if (snapshot.readOnly || snapshot.original?.read_only) return undefined; + activeRequest.current?.controller.abort(); + const request = new AbortController(); + activeRequest.current = { controller: request, draft: snapshot.draft, original: snapshot.original }; + setBusy(label); + try { + const response = await operation(request.signal); + if (request.signal.aborted || latest.current.draft !== snapshot.draft || + latest.current.original !== snapshot.original || latest.current.readOnly) return undefined; + const value = describe ? describe(response) : connectorFeedback(response); + let resultDraft = snapshot.draft; + if (value.success && apply) { + resultDraft = apply(snapshot.draft, response); + snapshot.onChange((current) => current === snapshot.draft ? resultDraft : current); + } + setFeedback({ value, draft: resultDraft }); + return value.success ? response : undefined; + } catch (error) { + if (!request.signal.aborted && latest.current.draft === snapshot.draft && + latest.current.original === snapshot.original) { + setFeedback({ value: connectorFeedback(error), draft: snapshot.draft }); + } + return undefined; + } finally { + if (activeRequest.current?.controller === request) { + activeRequest.current = null; + setBusy(null); + } + } + } + return { busy, feedback: feedback?.value ?? null, stale: Boolean(feedback && feedback.draft !== props.draft), run }; +} + +export function ConnectorFeedbackPanel({ feedback, stale }: { feedback: ConnectorFeedback | null; stale?: boolean }) { + if (!feedback) return null; + const details = Object.entries(feedback.details).filter(([, value]) => ['string', 'number', 'boolean'].includes(typeof value)); + return ( +
+

{feedback.message}

+ {stale ?

This result describes an earlier draft. Run the command again to check your current configuration.

: null} + {feedback.errors.length ?
    + {feedback.errors.map((message, index) =>
  • {message}
  • )} +
: null} + {feedback.warnings.length ?
+

Warnings

+
    + {feedback.warnings.map((message, index) =>
  • {message}
  • )} +
+
: null} + {details.length ?
+ {details.map(([name, value]) =>
+
{name.replaceAll('_', ' ')}:
{String(value)}
+
)} +
: null} +
+ ); +} + +export function ActionConnectionCheck({ + props, definition, kind = 'native', disabled = false, configurationError = false, +}: { + props: ActionConnectorProps; + definition?: ActionTypeDefinition; + kind?: 'native' | 'openapi' | 'mcp'; + disabled?: boolean; + configurationError?: boolean; +}) { + const { draft, original } = props; + const readOnly = props.readOnly || Boolean(original?.read_only); + const request = useConnectorRequest(props); + if (kind === 'native') { + const native = nativeActionDefinition(draft.type); + if (!native.testPath || !definition) return null; + return ( + +
+ void request.run('Testing connection…', (signal) => testWorkspaceAction(draft, original, definition, signal, connectorTestScope(props)))}> + {request.busy || 'Test connection'} + +
+ +
+ ); + } + const label = kind === 'mcp' ? 'MCP' : 'OpenAPI'; + return ( + +
+ void request.run(`Validating ${label} configuration…`, (signal) => validateApiConnector(draft, original, kind, signal, connectorTestScope(props)))}> + Validate {label} configuration + + void request.run(`Testing ${label} connection…`, (signal) => testApiConnector(draft, original, kind, signal, connectorTestScope(props)))}> + Test {label} connection + + {request.busy ?

{request.busy}

: null} +
+ {readOnly ?

Provided actions are read-only. Validation and connection testing are disabled.

: null} + {!readOnly && configurationError ?

Resolve the highlighted configuration errors before validating or connecting.

: null} + +
+ ); +} diff --git a/application/v2_ui/src/components/workspaceActions/ActionSchemaFields.tsx b/application/v2_ui/src/components/workspaceActions/ActionSchemaFields.tsx index caaa1952e..06509463c 100644 --- a/application/v2_ui/src/components/workspaceActions/ActionSchemaFields.tsx +++ b/application/v2_ui/src/components/workspaceActions/ActionSchemaFields.tsx @@ -1,10 +1,10 @@ // ActionSchemaFields.tsx -import { useId, useState } from 'react'; +import { useId } from 'react'; import { Plus, Trash2 } from 'lucide-react'; import { GlassButton } from '../ui/primitives'; import { ActionField, ActionJsonInput, ActionSecretInput, ACTION_INPUT_CLASS } from './ActionFields'; -import { EditorPanel, EditorPanelFieldset, EditorRow } from '../workspace/EditorLayout'; +import { EditorPanelFieldset, EditorRow } from '../workspace/EditorLayout'; import { EDITOR_SECRET_MASK, isRecord, pointerPart, type EditorSchema } from '../../lib/workspaceAuthoring'; import { actionArrayRemovalError, actionFieldError, actionHasStoredArraySecrets, actionSchemaDefaults, @@ -157,59 +157,17 @@ export function ActionSchemaFields({ const resolved = resolveActionSchema(schema, root); const value = actionValueAt(props.draft, path); const record = isRecord(value) ? value : {}; - const covered = (pointer: string) => coveredPaths.some((known) => pointer === known || pointer.startsWith(`${known}/`)); + const covered = (pointer: string) => coveredPaths.some((known) => + pointer === known || pointer.startsWith(`${known}/`) || known.startsWith(`${pointer}/`)); const properties = Object.entries(resolved.properties ?? {}).filter(([key]) => !covered(`${path}/${pointerPart(key)}`)); - const unknown = Object.keys(record).filter((key) => !Object.hasOwn(resolved.properties ?? {}, key) && !covered(`${path}/${pointerPart(key)}`)); + void record; + void allowCustom; return (
{properties.map(([key, definition]) => ( ))} - {unknown.map((key) =>
- - {!props.readOnly ? props.onChange((draft) => withActionValue(draft, `${path}/${pointerPart(key)}`, undefined))}> - Remove field - : null} -
)} - {allowCustom && !props.readOnly ? : null}
); } - -function ActionAddCustomField({ props, path, existing }: { props: ActionConnectorProps; path: string; existing: string[] }) { - const id = useId(); - const [name, setName] = useState(''); - const [type, setType] = useState('text'); - const [error, setError] = useState(''); - return ( - -
- - { setName(event.target.value); setError(''); }} /> - - - - -
-
- { - const raw = name.trim(); - const key = type === 'secret' && !raw.endsWith('__Secret') ? `${raw}__Secret` : raw; - if (!raw || existing.includes(key)) { setError(raw ? 'That property already exists.' : 'Enter a property name.'); return; } - const initial = type === 'number' ? 0 : type === 'boolean' ? false : type === 'object' ? {} : type === 'array' ? [] : ''; - props.onChange((draft) => withActionValue(draft, `${path}/${pointerPart(key)}`, initial)); - setName(''); setError(''); - }}> Add field -
-
- ); -} diff --git a/application/v2_ui/src/components/workspaceActions/DocumentSearchActionConfiguration.tsx b/application/v2_ui/src/components/workspaceActions/DocumentSearchActionConfiguration.tsx new file mode 100644 index 000000000..7327377de --- /dev/null +++ b/application/v2_ui/src/components/workspaceActions/DocumentSearchActionConfiguration.tsx @@ -0,0 +1,448 @@ +// DocumentSearchActionConfiguration.tsx + +import { useMemo } from 'react'; +import { RotateCcw } from 'lucide-react'; +import { useBootstrapStore } from '../../stores/bootstrapStore'; +import { changeActionField } from '../../lib/workspaceActionLogic'; +import type { ActionConnectorProps } from '../../lib/workspaceActionTypes'; +import type { WorkspaceRef } from '../../lib/types'; +import { EditorDependents, EditorFieldRow, EditorFieldset, EditorSwitch } from '../workspace/EditorLayout'; + +const INPUT_CLASS = 'form-control w-full rounded-lg border border-edge bg-surface-1 px-3 py-2 text-sm text-text-1 focus:border-accent focus:outline-none'; +const SELECT_CLASS = 'form-select w-full rounded-lg border border-edge bg-surface-1 px-3 py-2 text-sm text-text-1 focus:border-accent focus:outline-none'; +const RANGE_CLASS = 'form-range w-full accent-[var(--accent)] disabled:cursor-not-allowed disabled:opacity-50'; +const VALID_SCOPES = ['personal', 'group', 'public'] as const; + +type DocumentSearchScope = typeof VALID_SCOPES[number]; +type WindowSizingMode = 'automatic' | 'fixed' | 'percent'; + +function numberValue(value: unknown, fallback: number, min: number, max: number): number { + const numeric = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(max, Math.max(min, Math.round(numeric))); +} + +function stringArrayValue(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + return value.reduce((items, item) => { + const normalized = String(item || '').trim(); + if (normalized && !seen.has(normalized)) { + seen.add(normalized); + items.push(normalized); + } + return items; + }, []); +} + +function scopeArrayValue(value: unknown): DocumentSearchScope[] { + const values = stringArrayValue(value).filter((item): item is DocumentSearchScope => + VALID_SCOPES.includes(item as DocumentSearchScope)); + return values.length ? values : [...VALID_SCOPES]; +} + +function parsePageTargetLength(value: unknown, min: number, max: number): { pages: number; custom: '' } | { pages: null; custom: string } { + if (typeof value === 'number' && Number.isFinite(value)) { + return { pages: numberValue(value, min, min, max), custom: '' }; + } + const text = String(value ?? '').trim(); + if (!text) return { pages: min, custom: '' }; + const match = text.match(/^(\d+)(?:\s*pages?)?$/i); + if (!match) return { pages: null, custom: text }; + return { pages: numberValue(Number(match[1]), min, min, max), custom: '' }; +} + +function joinNames(ids: string[], options: WorkspaceRef[]): string { + const names = ids.map((id) => options.find((option) => option.id === id)?.name || id); + return names.length ? names.join(', ') : 'All I can access'; +} + +function updateArrayValue(current: string[], value: string, enabled: boolean): string[] { + if (!enabled) return current.filter((item) => item !== value); + return current.includes(value) ? current : [...current, value]; +} + +function WorkspaceMultiSelect({ + label, + description, + options, + selectedIds, + disabled, + onChange, +}: { + label: string; + description: string; + options: WorkspaceRef[]; + selectedIds: string[]; + disabled: boolean; + onChange: (ids: string[]) => void; +}) { + const selected = useMemo(() => new Set(selectedIds), [selectedIds]); + return ( +
+
+

{label}

+

{description}

+
+ {options.length ? ( +
+ {options.map((option) => ( + + ))} +
+ ) : ( +

No workspaces are available from the current bootstrap data.

+ )} +
+ ); +} + +function TargetLengthControl({ + id, + label, + help, + value, + min, + max, + disabled, + onChange, +}: { + id: string; + label: string; + help: string; + value: unknown; + min: number; + max: number; + disabled: boolean; + onChange: (value: string) => void; +}) { + const parsed = parsePageTargetLength(value, min, max); + return ( + {parsed.pages} pages} + > + {parsed.pages === null ? ( +
+

Current custom value: {parsed.custom}

+

Use reset to switch this action to page-based target lengths.

+ +
+ ) : ( +
+ onChange(`${Number(event.target.value)} pages`)} + /> +
+ onChange(`${numberValue(event.target.valueAsNumber, min, min, max)} pages`)} + /> + pages +
+
+ {min} page + {max} pages +
+
+ )} +
+ ); +} + +export function DocumentSearchActionConfiguration({ draft, onChange, readOnly }: ActionConnectorProps) { + const groups = useBootstrapStore((state) => state.data?.scope?.groups ?? []); + const publicWorkspaces = useBootstrapStore((state) => state.data?.scope?.public_workspaces ?? []); + const fields = draft.additionalFields; + const scopes = scopeArrayValue(fields.allowed_scopes); + const groupIds = stringArrayValue(fields.allowed_group_ids); + const publicWorkspaceIds = stringArrayValue(fields.allowed_public_workspace_ids); + const topN = numberValue(fields.default_top_n, draft.type === 'search' ? 12 : 50, 1, 500); + const sliderTopN = Math.min(100, Math.max(5, topN)); + const windowSize = numberValue(fields.default_window_size, 10, 1, 100); + const windowPercent = numberValue(fields.default_window_percent, 25, 1, 100); + const sizingMode: WindowSizingMode = fields.default_window_percent !== undefined + ? 'percent' + : fields.default_window_size !== undefined ? 'fixed' : 'automatic'; + const updateField = (path: string, value: unknown) => onChange((current) => changeActionField(current, path, value)); + const updateWindowSizing = (mode: WindowSizingMode) => onChange((current) => { + let next = changeActionField(current, '/additionalFields/default_window_size', undefined); + next = changeActionField(next, '/additionalFields/default_window_percent', undefined); + if (mode === 'fixed') next = changeActionField(next, '/additionalFields/default_window_size', windowSize); + if (mode === 'percent') next = changeActionField(next, '/additionalFields/default_window_percent', windowPercent); + return next; + }); + const toggleScope = (scope: DocumentSearchScope, enabled: boolean) => { + updateField('/additionalFields/allowed_scopes', updateArrayValue(scopes, scope, enabled)); + }; + + return ( +
+

+ 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. +

+ + toggleScope('personal', enabled)} + /> + toggleScope('group', enabled)} + /> + {scopes.includes('group') ? ( + + + + + {groupIds.length ? ( + updateField('/additionalFields/allowed_group_ids', ids)} + /> + ) : null} + + ) : null} + toggleScope('public', enabled)} + /> + {scopes.includes('public') ? ( + + + + + {publicWorkspaceIds.length ? ( + updateField('/additionalFields/allowed_public_workspace_ids', ids)} + /> + ) : null} + + ) : null} + + {topN}} + > + updateField('/additionalFields/default_top_n', Number(event.target.value))} + /> + updateField('/additionalFields/default_top_n', numberValue(event.target.valueAsNumber, topN, 1, 500))} + /> + + + + + + + + + {sizingMode === 'fixed' ? ( + + updateField('/additionalFields/default_window_size', numberValue(event.target.valueAsNumber, windowSize, 1, 100))} + /> + + ) : null} + {sizingMode === 'percent' ? ( + + updateField('/additionalFields/default_window_percent', numberValue(event.target.valueAsNumber, windowPercent, 1, 100))} + /> + + ) : null} + + +