diff --git a/api/.importlinter b/api/.importlinter index c6649dc4069c97..c55841d85722e2 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -81,7 +81,7 @@ ignore_imports = core.app.task_pipeline.message_cycle_manager -> services.annotation_service core.helper.credential_utils -> services.enterprise.plugin_manager_service core.helper.credential_utils -> services.system_feature_service - core.mcp.auth_client -> services.tools.legacy_mcp_tools_manage_service + core.mcp.auth_client -> services.tools.mcp_tools_manage_service core.provider_manager -> services.credit_pool_service core.provider_manager -> services.feature_service core.rag.datasource.retrieval_service -> services.knowledge.external.service diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index 825e33ca74b369..a2e04abed4aba1 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -7,10 +7,9 @@ from flask_restx import Resource from pydantic import BaseModel, ConfigDict, Field, RootModel from werkzeug.datastructures import FileStorage -from werkzeug.exceptions import Forbidden from configs import dify_config -from controllers.common.errors import InvalidArgumentError +from controllers.common.errors import AccessDeniedError, InvalidArgumentError from controllers.common.fields import BinaryFileResponse, SuccessResponse from controllers.common.rbac import RBACCheck, Workspace from controllers.common.schema import ( @@ -49,7 +48,7 @@ from core.tools.entities.api_entities import ToolProviderApiEntity from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_entities import ToolProviderType -from core.tools.tool_manager import ToolManager +from extensions.ext_application_services import application_services from extensions.ext_database import db from fields.base import ResponseModel from graphon.model_runtime.utils.encoders import jsonable_encoder @@ -71,7 +70,8 @@ from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService from services.plugin.plugin_parameter_service import PluginParameterService from services.plugin.plugin_permission_service import PluginPermissionService -from services.tools.legacy_tools_transform_service import ToolTransformService +from services.tools.tool_manager import ToolManager +from services.tools.tools_transform_service import ToolTransformService _PLUGIN_PACKAGE_UPLOAD_PARAMS = { "pkg": { @@ -537,7 +537,7 @@ def _list_hardcoded_builtin_tool_providers( """List builtin providers using the same search and tag semantics as category plugins.""" db_builtin_providers = { str(ToolProviderID(provider.provider)): provider - for provider in ToolManager.list_default_builtin_providers(tenant_id) + for provider in application_services().tools.tool_providers.default_builtin(tenant_id=tenant_id) } builtin_providers = [] @@ -1116,7 +1116,7 @@ class PluginChangePermissionApi(Resource): @model_validate(ParserPermissionChange) def post(self, req_data: ParserPermissionChange, tenant_id: str, user: Account): if not user.is_admin_or_owner: - raise Forbidden() + raise AccessDeniedError() set_permission_result = PluginPermissionService.change_permission( tenant_id, req_data.install_permission, req_data.debug_permission, session=db.session() @@ -1229,7 +1229,7 @@ class PluginChangeAutoUpgradeApi(Resource): @model_validate(ParserAutoUpgradeChange) def post(self, req_data: ParserAutoUpgradeChange, tenant_id: str, user: Account): if not dify_config.RBAC_ENABLED and not user.is_admin_or_owner: - raise Forbidden() + raise AccessDeniedError() auto_upgrade = req_data.auto_upgrade set_auto_upgrade_strategy_result = PluginAutoUpgradeService.change_strategy( diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index 2b61304e46b961..df6dc9022b92ec 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -16,9 +16,9 @@ model_validator, ) from sqlalchemy.orm import sessionmaker -from werkzeug.exceptions import Forbidden from configs import dify_config +from controllers.common.errors import AccessDeniedError from controllers.common.fields import SimpleResultResponse from controllers.common.rbac import RBACCheck, Workspace from controllers.common.schema import ( @@ -73,13 +73,11 @@ # from models.provider_ids import ToolProviderID from services.plugin.oauth_service import OAuthProxyService -from services.tools.legacy_api_tools_manage_service import ApiToolManageService, ApiToolPreviewResult -from services.tools.legacy_builtin_tools_manage_service import BuiltinToolManageService -from services.tools.legacy_mcp_tools_manage_service import MCPToolManageService, OAuthDataType -from services.tools.legacy_tools_manage_service import ToolCommonService -from services.tools.legacy_tools_transform_service import ToolTransformService -from services.tools.legacy_workflow_tools_manage_service import WorkflowToolManageService +from services.tools.api_tools_manage_service import ApiToolManageService, ApiToolPreviewResult +from services.tools.builtin_tools_manage_service import BuiltinToolManageService +from services.tools.mcp_tools_manage_service import MCPToolManageService, OAuthDataType from services.tools.tool_labels_service import ToolLabelsService +from services.tools.tools_manage_service import ToolCommonService logger = logging.getLogger(__name__) @@ -508,7 +506,11 @@ def get(self, tenant_id: str, user: Account): return _dump_tool_provider_payload_list( ToolCommonService.list_tool_providers( - user.id, tenant_id, cast(ToolProviderTypeApiLiteral | None, query.type) + user.id, + tenant_id, + cast(ToolProviderTypeApiLiteral | None, query.type), + workflow_queries=application_services().tools.workflow_queries, + tool_providers=application_services().tools.tool_providers, ), ) @@ -753,9 +755,7 @@ def get(self, tenant_id: str, user: Account): return dump_response( ToolApiListResponse, ApiToolManageService.list_api_tool_provider_tools( - user.id, - tenant_id, - query.provider, + user.id, tenant_id, query.provider, tool_providers=application_services().tools.tool_providers ), ) @@ -831,9 +831,7 @@ def get(self, tenant_id: str, user: Account): return dump_response( ApiProviderDetailResponse, ApiToolManageService.get_api_tool_provider( - user.id, - tenant_id, - query.provider, + user.id, tenant_id, query.provider, tool_providers=application_services().tools.tool_providers ), ) @@ -913,7 +911,7 @@ class ToolWorkflowProviderCreateApi(Resource): def post(self, req_data: WorkflowToolCreatePayload, tenant_id: str, user: Account): return dump_response( SimpleResultResponse, - WorkflowToolManageService.create_workflow_tool( + application_services().tools.workflows.create_workflow_tool( user_id=user.id, tenant_id=tenant_id, workflow_app_id=req_data.workflow_app_id, @@ -943,7 +941,7 @@ class ToolWorkflowProviderUpdateApi(Resource): def post(self, req_data: WorkflowToolUpdatePayload, tenant_id: str, user: Account): return dump_response( SimpleResultResponse, - WorkflowToolManageService.update_workflow_tool( + application_services().tools.workflows.update_workflow_tool( user.id, tenant_id, req_data.workflow_tool_id, @@ -973,7 +971,7 @@ class ToolWorkflowProviderDeleteApi(Resource): def post(self, req_data: WorkflowToolDeletePayload, tenant_id: str, user: Account): return dump_response( SimpleResultResponse, - WorkflowToolManageService.delete_workflow_tool( + application_services().tools.workflows.delete_workflow_tool( user.id, tenant_id, req_data.workflow_tool_id, @@ -996,13 +994,13 @@ def get(self, tenant_id: str, user: Account): query = query_params_from_request(WorkflowToolGetQuery) if query.workflow_tool_id: - tool = WorkflowToolManageService.get_workflow_tool_by_tool_id( + tool = application_services().tools.workflows.get_workflow_tool_by_tool_id( user.id, tenant_id, query.workflow_tool_id, ) elif query.workflow_app_id: - tool = WorkflowToolManageService.get_workflow_tool_by_app_id( + tool = application_services().tools.workflows.get_workflow_tool_by_app_id( user.id, tenant_id, query.workflow_app_id, @@ -1029,7 +1027,7 @@ def get(self, tenant_id: str, user: Account): return dump_response( ToolApiListResponse, - WorkflowToolManageService.list_single_workflow_tools( + application_services().tools.workflows.list_single_workflow_tools( user.id, tenant_id, query.workflow_tool_id, @@ -1052,8 +1050,7 @@ def get(self, tenant_id: str, user: Account): [ provider.to_dict() for provider in BuiltinToolManageService.list_builtin_tools( - user.id, - tenant_id, + user.id, tenant_id, tool_providers=application_services().tools.tool_providers ) ], ) @@ -1071,7 +1068,7 @@ def get(self, tenant_id: str): [ provider.to_dict() for provider in ApiToolManageService.list_api_tools( - tenant_id, + tenant_id, tool_providers=application_services().tools.tool_providers ) ], ) @@ -1091,7 +1088,7 @@ def get(self, tenant_id: str, user: Account): return _dump_tool_provider_payload_list( [ provider.to_dict() - for provider in WorkflowToolManageService.list_tenant_workflow_tools( + for provider in application_services().tools.workflows.list_tenant_workflow_tools( user.id, tenant_id, ) @@ -1130,9 +1127,11 @@ def get(self, tenant_id: str, user: Account, provider: str): plugin_id = tool_provider.plugin_id provider_name = tool_provider.provider_name - oauth_client_params = BuiltinToolManageService.get_oauth_client(tenant_id=tenant_id, provider=provider) + oauth_client_params = BuiltinToolManageService.get_oauth_client( + tenant_id=tenant_id, provider=provider, tool_providers=application_services().tools.tool_providers + ) if oauth_client_params is None: - raise Forbidden("no oauth available client config found for this tool provider") + raise AccessDeniedError("no oauth available client config found for this tool provider") # Visibility is chosen by the user in the frontend before the redirect, # then read back in the callback below when the credential is created. @@ -1186,11 +1185,11 @@ class ToolOAuthCallback(Resource): def get(self, provider: str): context_id = request.cookies.get("context_id") if not context_id: - raise Forbidden("context_id not found") + raise AccessDeniedError("context_id not found") context = OAuthProxyService.use_proxy_context(context_id) if context is None: - raise Forbidden("Invalid context_id") + raise AccessDeniedError("Invalid context_id") tool_provider = ToolProviderID(provider) plugin_id = tool_provider.plugin_id @@ -1199,9 +1198,11 @@ def get(self, provider: str): tenant_id: str = context["tenant_id"] oauth_handler = OAuthHandler() - oauth_client_params = BuiltinToolManageService.get_oauth_client(tenant_id, provider) + oauth_client_params = BuiltinToolManageService.get_oauth_client( + tenant_id, provider, tool_providers=application_services().tools.tool_providers + ) if oauth_client_params is None: - raise Forbidden("no oauth available client config found for this tool provider") + raise AccessDeniedError("no oauth available client config found for this tool provider") try: OAuthProxyService.validate_plugin_context( @@ -1211,7 +1212,7 @@ def get(self, provider: str): plugin_unique_identifier=oauth_client_params.plugin_unique_identifier, ) except ValueError as error: - raise Forbidden(str(error)) from error + raise AccessDeniedError(str(error)) from error redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider}/tool/callback" credentials_response = oauth_handler.get_credentials( @@ -1401,7 +1402,7 @@ def post(self, req_data: MCPProviderCreatePayload, tenant_id: str, user: Account # 1) Create provider in a short transaction (no network I/O inside) with session_factory.create_session() as session, session.begin(): service = MCPToolManageService(session=session) - result = service.create_provider( + provider_id = service.create_provider( tenant_id=tenant_id, user_id=user.id, server_url=req_data.server_url, @@ -1416,6 +1417,10 @@ def post(self, req_data: MCPProviderCreatePayload, tenant_id: str, user: Account identity_mode=_resolve_identity_mode(req_data.identity_mode, current=IdentityMode.OFF), ) + result = MCPToolManageService.provider_response( + tenant_id=tenant_id, provider_id=provider_id, tool_providers=application_services().tools.tool_providers + ) + # 2) Try to fetch tools immediately after creation so they appear without a second save. # Perform network I/O outside any DB session to avoid holding locks. try: @@ -1432,7 +1437,9 @@ def post(self, req_data: MCPProviderCreatePayload, tenant_id: str, user: Account db_provider.authed = reconnect.authed db_provider.tools = reconnect.tools - result = ToolTransformService.mcp_provider_to_user_provider(db_provider) + result = MCPToolManageService.provider_response( + tenant_id=tenant_id, provider_id=result.id, tool_providers=application_services().tools.tool_providers + ) except Exception: # Best-effort: if initial fetch fails (e.g., auth required), return created provider as-is logger.warning("Failed to fetch MCP tools after creation", exc_info=True) @@ -1596,10 +1603,10 @@ class ToolMCPDetailApi(Resource): @rbac_permission_required(RBACCheck(RBACPermission.MCP_MANAGE, Workspace())) @with_current_tenant_id def get(self, tenant_id: str, provider_id: str): - with sessionmaker(db.engine).begin() as session: - service = MCPToolManageService(session=session) - provider = service.get_provider_by_id(provider_id=provider_id, tenant_id=tenant_id) - return _dump_tool_provider_payload(ToolTransformService.mcp_provider_to_user_provider(provider).to_dict()) + provider = MCPToolManageService.provider_response( + tenant_id=tenant_id, provider_id=provider_id, tool_providers=application_services().tools.tool_providers + ) + return _dump_tool_provider_payload(provider.to_dict()) @console_ns.route("/workspaces/current/tools/mcp") @@ -1610,12 +1617,11 @@ class ToolMCPListAllApi(Resource): @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str): - with sessionmaker(db.engine).begin() as session: - service = MCPToolManageService(session=session) - # Skip sensitive data decryption for list view to improve performance - tools = service.list_providers(tenant_id=tenant_id, include_sensitive=False) - - return _dump_tool_provider_payload_list([tool.to_dict() for tool in tools]) + # Skip sensitive data decryption for list view to improve performance. + tools = MCPToolManageService.list_providers( + tenant_id=tenant_id, include_sensitive=False, tool_providers=application_services().tools.tool_providers + ) + return _dump_tool_provider_payload_list([tool.to_dict() for tool in tools]) @console_ns.route("/workspaces/current/tool-provider/mcp/update/") diff --git a/api/controllers/openapi/tool_providers.py b/api/controllers/openapi/tool_providers.py index 248b692b00538e..e87a54ed09aede 100644 --- a/api/controllers/openapi/tool_providers.py +++ b/api/controllers/openapi/tool_providers.py @@ -13,7 +13,11 @@ from controllers.common.rbac import RBACPermission from controllers.openapi import openapi_ns from controllers.openapi._contract import Example, Kind, endpoint, op_of -from controllers.openapi._errors import CredentialNotFound, CredentialOAuthOnly, ProviderNotFound +from controllers.openapi._errors import ( + CredentialNotFound, + CredentialOAuthOnly, + ProviderNotFound, +) from controllers.openapi._i18n import localized from controllers.openapi._models import ( CredentialFormField, @@ -33,21 +37,27 @@ ) from controllers.openapi._search import matches from controllers.openapi.auth.context import Context -from controllers.openapi.auth.requirements import ADMIN_ROLES, WORKSPACE_READ_GUARDS, workspace_write_guards -from core.db.session_factory import session_factory +from controllers.openapi.auth.requirements import ( + ADMIN_ROLES, + WORKSPACE_READ_GUARDS, + workspace_write_guards, +) from core.entities.provider_entities import ProviderConfig from core.plugin.entities.plugin_daemon import CredentialType from core.tools.__base.tool import ToolParameter from core.tools.builtin_tool.provider import BuiltinToolProviderController -from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity, ToolProviderCredentialApiEntity +from core.tools.entities.api_entities import ( + ToolApiEntity, + ToolProviderApiEntity, + ToolProviderCredentialApiEntity, +) from core.tools.errors import ToolProviderNotFoundError from core.tools.plugin_tool.provider import PluginToolProviderController -from core.tools.tool_manager import ToolManager from extensions.ext_application_services import application_services -from services.tools.legacy_api_tools_manage_service import ApiToolManageService -from services.tools.legacy_builtin_tools_manage_service import BuiltinToolManageService -from services.tools.legacy_mcp_tools_manage_service import MCPToolManageService -from services.tools.legacy_workflow_tools_manage_service import WorkflowToolManageService +from services.tools.api_tools_manage_service import ApiToolManageService +from services.tools.builtin_tools_manage_service import BuiltinToolManageService +from services.tools.mcp_tools_manage_service import MCPToolManageService +from services.tools.tool_manager import ToolManager _PROVIDER_EXAMPLE: Final = "langgenius/tavily/tavily" _ToolProviderController = BuiltinToolProviderController | PluginToolProviderController @@ -119,16 +129,27 @@ def _write_response(ctx: Context, provider: str, credential_id: str) -> Credenti def _mcp_providers(ctx: Context) -> list[ToolProviderApiEntity]: - with session_factory.create_session() as session: - return MCPToolManageService(session=session).list_providers(tenant_id=ctx.workspace.id, include_sensitive=False) + return MCPToolManageService.list_providers( + tenant_id=ctx.workspace.id, + include_sensitive=False, + tool_providers=application_services().tools.tool_providers, + ) _TOOL_SOURCES: Final[dict[ToolSource, Callable[[Context], list[ToolProviderApiEntity]]]] = { - ToolSource.BUILTIN: lambda ctx: BuiltinToolManageService.list_builtin_tools(ctx.account.id, ctx.workspace.id), - ToolSource.WORKFLOW: lambda ctx: WorkflowToolManageService.list_tenant_workflow_tools( - ctx.account.id, ctx.workspace.id + ToolSource.BUILTIN: lambda ctx: BuiltinToolManageService.list_builtin_tools( + ctx.account.id, + ctx.workspace.id, + tool_providers=application_services().tools.tool_providers, + ), + ToolSource.WORKFLOW: lambda ctx: application_services().tools.workflows.list_tenant_workflow_tools( + ctx.account.id, + ctx.workspace.id, + ), + ToolSource.API: lambda ctx: ApiToolManageService.list_api_tools( + ctx.workspace.id, + tool_providers=application_services().tools.tool_providers, ), - ToolSource.API: lambda ctx: ApiToolManageService.list_api_tools(ctx.workspace.id), ToolSource.MCP: _mcp_providers, } diff --git a/api/core/mcp/auth_client.py b/api/core/mcp/auth_client.py index da2e04d12c2cc5..999c636c12b688 100644 --- a/api/core/mcp/auth_client.py +++ b/api/core/mcp/auth_client.py @@ -87,7 +87,7 @@ def _handle_auth_error(self, error: MCPAuthError) -> None: # Create a temporary session only for auth retry # This session is short-lived and only exists during the auth operation - from services.tools.legacy_mcp_tools_manage_service import MCPToolManageService + from services.tools.mcp_tools_manage_service import MCPToolManageService with Session(db.engine) as session, session.begin(): mcp_service = MCPToolManageService(session=session) diff --git a/api/core/plugin/impl/datasource.py b/api/core/plugin/impl/datasource.py index c47f95838b7e9e..7bc8436d954cd9 100644 --- a/api/core/plugin/impl/datasource.py +++ b/api/core/plugin/impl/datasource.py @@ -19,7 +19,7 @@ from core.schemas.resolver import resolve_dify_schema_refs from models.provider_ids import DatasourceProviderID, GenericProviderID from services.plugin.dispatch_identity import plugin_dispatch_headers -from services.tools.legacy_tools_transform_service import ToolTransformService +from services.tools.tools_transform_service import ToolTransformService class PluginDatasourceManager(BasePluginClient): diff --git a/api/core/tools/mcp_tool/provider.py b/api/core/tools/mcp_tool/provider.py index edcb0c88505d3c..d1a0da0fbe6072 100644 --- a/api/core/tools/mcp_tool/provider.py +++ b/api/core/tools/mcp_tool/provider.py @@ -15,7 +15,7 @@ ) from core.tools.mcp_tool.tool import MCPTool from models.tools import MCPToolProvider -from services.tools.legacy_tools_transform_service import ToolTransformService +from services.tools.tools_transform_service import ToolTransformService class MCPToolProviderController(ToolProviderController[ToolProviderEntityWithPlugin, MCPTool]): diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index 60ff42b12edff4..608a73a5bc0057 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -39,6 +39,7 @@ ) from extensions.application_services.rbac import RBACServices, build_rbac_services from extensions.application_services.resource_access_token import build_resource_access_token_service +from extensions.application_services.tools import ToolServices, build_tool_services from extensions.application_services.trial_app import TrialAppServices, build_trial_app_services from extensions.application_services.workflow import build_workflow_execution_dependencies, build_workflow_suggestions from extensions.application_services.workflow_variables import ( @@ -303,6 +304,7 @@ class AppScopedEndUserServices: @dataclass(frozen=True, slots=True) class ApplicationServices: + tools: ToolServices workflow_runtime: AppGenerationRuntime workflow_variables: WorkflowVariableService console_workflow_variables: ConsoleWorkflowVariableService @@ -616,6 +618,7 @@ def build_application_services( ) return ApplicationServices( + tools=build_tool_services(database_client), workflow_runtime=build_workflow_execution_dependencies(database_client), workflow_variables=workflow_variables, console_workflow_variables=build_console_workflow_variables( @@ -891,6 +894,8 @@ def init_app(app: Flask) -> None: from extensions.ext_login import bind_account_loader app.extensions["knowledge_fs_node_dependencies"] = build_knowledge_fs_node_dependencies + ToolManager.register_hardcoded_provider_class(KnowledgeFSProvider) + # Legacy workflow execution and saved trace extras still resolve providers here. LegacyToolManager.register_hardcoded_provider_class(KnowledgeFSProvider) LegacyToolManager.configure_credential_resolver(resolve_tool_credentials) ToolManager.configure_credential_resolver(resolve_tool_credentials) diff --git a/api/services/plugin/plugin_parameter_service.py b/api/services/plugin/plugin_parameter_service.py index d34c65ff714859..671e1096dad907 100644 --- a/api/services/plugin/plugin_parameter_service.py +++ b/api/services/plugin/plugin_parameter_service.py @@ -7,7 +7,6 @@ from core.plugin.entities.parameters import PluginParameterOption from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.impl.dynamic_select import DynamicSelectClient -from core.tools.tool_manager import ToolManager from core.tools.utils.encryption import create_tool_provider_encrypter from core.trigger.entities.api_entities import TriggerProviderSubscriptionApiEntity from core.trigger.entities.entities import SubscriptionBuilder @@ -15,6 +14,7 @@ from extensions.ext_database import db from models.provider_ids import TriggerProviderID from models.tools import BuiltinToolProvider +from services.tools.tool_manager import ToolManager from services.trigger.trigger_provider_service import TriggerProviderService from services.trigger.trigger_subscription_builder_service import TriggerSubscriptionBuilderService diff --git a/api/services/tools/builtin/credentials.py b/api/services/tools/builtin/credentials.py index 6fbec3dbf47eee..a5409705f4d856 100644 --- a/api/services/tools/builtin/credentials.py +++ b/api/services/tools/builtin/credentials.py @@ -18,6 +18,7 @@ def resolve_oauth_client( provider_id: str, ) -> PluginOAuthClientConfig | None: provider = ToolProviderID(provider_id) + plugin_unique_identifier = controller.plugin_unique_identifier config = providers.oauth_client( tenant_id=tenant_id, plugin_id=provider.plugin_id, @@ -30,14 +31,14 @@ def resolve_oauth_client( cache=NoOpProviderCredentialCache(), ) return PluginOAuthClientConfig( - controller.plugin_unique_identifier, + plugin_unique_identifier, dict(encrypter.decrypt(config.tenant_params)), ) if config.system_params is None: return None - if not PluginService.is_plugin_verified(tenant_id, controller.plugin_unique_identifier): + if not PluginService.is_plugin_verified(tenant_id, plugin_unique_identifier): return None return PluginOAuthClientConfig( - controller.plugin_unique_identifier, + plugin_unique_identifier, dict(decrypt_system_params(config.system_params)), ) diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index a701c21410163b..bb54984795a49a 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -23,7 +23,7 @@ ) from core.tools.errors import ToolProviderNotFoundError from core.tools.plugin_tool.provider import PluginToolProviderController -from core.tools.utils.encryption import create_provider_encrypter +from core.tools.utils.encryption import ProviderConfigCache, create_provider_encrypter from extensions.ext_database import db from extensions.ext_redis import redis_client from models.account import Account @@ -156,6 +156,7 @@ def update_builtin_tool_provider( """ update builtin tool provider """ + cache: ProviderConfigCache | None = None with sessionmaker(bind=db.engine).begin() as session: # get if the provider exists db_provider = session.scalar( @@ -191,8 +192,6 @@ def update_builtin_tool_provider( # encrypt credentials db_provider.encrypted_credentials = json.dumps(encrypter.encrypt(new_credentials)) - cache.delete() - # update name if provided if name and name != db_provider.name: # check if the name is already used @@ -214,6 +213,8 @@ def update_builtin_tool_provider( except Exception as e: raise ValueError(str(e)) from e + if cache is not None: + cache.delete() return {"result": "success"} @staticmethod @@ -230,14 +231,21 @@ def add_builtin_tool_provider( """ add builtin tool provider """ - with sessionmaker(bind=db.engine).begin() as session: - try: - lock = f"builtin_tool_provider_create_lock:{tenant_id}_{provider}" - with redis_client.lock(lock, timeout=20): - provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) - if not provider_controller.need_credentials: - raise ValueError(f"provider {provider} does not need credentials") - + try: + provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) + if not provider_controller.need_credentials: + raise ValueError(f"provider {provider} does not need credentials") + + # Validation may call external services, so it runs before the lock is taken + # to keep its duration independent of the lock timeout. + if CredentialType.of(api_type).is_validate_allowed(): + provider_controller.validate_credentials(user_id, credentials) + + lock = f"builtin_tool_provider_create_lock:{tenant_id}_{provider}" + # The transaction commits before the lock is released, so concurrent requests + # see this credential when they check the count limit and the name. + with redis_client.lock(lock, timeout=20): + with sessionmaker(bind=db.engine).begin() as session: provider_count = ( session.scalar( select(func.count(BuiltinToolProvider.id)).where( @@ -252,10 +260,6 @@ def add_builtin_tool_provider( if provider_count >= BuiltinToolManageService.__MAX_BUILTIN_TOOL_PROVIDER_COUNT__: raise ValueError(f"you have reached the maximum number of providers for {provider}") - # validate credentials if allowed - if CredentialType.of(api_type).is_validate_allowed(): - provider_controller.validate_credentials(user_id, credentials) - # generate name if not provided if name is None or name == "": name = BuiltinToolManageService.generate_builtin_tool_provider_name( @@ -304,8 +308,8 @@ def add_builtin_tool_provider( session.add(db_provider) session.flush() credential_id = db_provider.id - except Exception as e: - raise ValueError(str(e)) from e + except Exception as e: + raise ValueError(str(e)) from e return {"result": "success", "id": credential_id} diff --git a/api/services/tools/tool_manager.py b/api/services/tools/tool_manager.py index cafb95e43254ee..8618e66cb19e4c 100644 --- a/api/services/tools/tool_manager.py +++ b/api/services/tools/tool_manager.py @@ -100,6 +100,7 @@ def __call__( class ToolManager: _builtin_provider_lock = Lock() _hardcoded_providers: dict[str, BuiltinToolProviderController] = {} + _external_hardcoded_provider_classes: list[type[BuiltinToolProviderController]] = [] _builtin_providers_loaded = False _builtin_tools_labels: dict[str, I18nObject | None] = {} _credential_resolver: ClassVar[ToolCredentialResolver | None] = None @@ -498,10 +499,7 @@ def get_hardcoded_provider_icon(cls, provider: str) -> tuple[str, str]: provider_controller = cls.get_hardcoded_provider(provider) absolute_path = path.join( - path.dirname(path.dirname(path.realpath(builtin_provider_module.__file__))), - "builtin_tool", - "providers", - provider, + provider_controller.provider_directory, "_assets", provider_controller.entity.identity.icon, ) @@ -595,18 +593,34 @@ def _list_hardcoded_providers(cls) -> Generator[BuiltinToolProviderController]: ), parent_type=BuiltinToolProviderController, ) - provider: BuiltinToolProviderController = provider_class() - cls._hardcoded_providers[provider.entity.identity.name] = provider - for tool in provider.get_tools(): - cls._builtin_tools_labels[tool.entity.identity.name] = tool.entity.identity.label - yield provider + yield cls._cache_hardcoded_provider(provider_class()) except Exception: logger.exception("load builtin provider %s", provider_path) continue + for external_provider_class in cls._external_hardcoded_provider_classes: + try: + yield cls._cache_hardcoded_provider(external_provider_class()) + except Exception: + logger.exception("load builtin provider %s", external_provider_class.__module__) # set builtin providers loaded cls._builtin_providers_loaded = True + @classmethod + def _cache_hardcoded_provider(cls, provider: BuiltinToolProviderController) -> BuiltinToolProviderController: + cls._hardcoded_providers[provider.entity.identity.name] = provider + for tool in provider.get_tools(): + cls._builtin_tools_labels[tool.entity.identity.name] = tool.entity.identity.label + return provider + + @classmethod + def register_hardcoded_provider_class(cls, provider_class: type[BuiltinToolProviderController]) -> None: + """Register a builtin provider owned outside ``core`` from the application composition root.""" + with cls._builtin_provider_lock: + if provider_class not in cls._external_hardcoded_provider_classes: + cls._external_hardcoded_provider_classes.append(provider_class) + cls._builtin_providers_loaded = False + @classmethod def load_hardcoded_providers_cache(cls): for _ in cls.list_hardcoded_providers(): diff --git a/api/services/tools/workflow/tool.py b/api/services/tools/workflow/tool.py index 2587fe4c40029d..1daace379c9df5 100644 --- a/api/services/tools/workflow/tool.py +++ b/api/services/tools/workflow/tool.py @@ -33,7 +33,6 @@ from models.model import App, EndUser from models.tool_runtime_contracts import WorkflowToolQueries from models.utils.file_input_compat import build_file_from_stored_mapping -from repositories.workflow.definition_repository import workflow_snapshot from services.workflow.contracts import WorkflowSnapshot from services.workflow.execution.ports import WorkflowRuntime from services.workflow_execution_identity import build_workflow_execution_identity @@ -300,6 +299,8 @@ def _resolve_user(self, user_id: str) -> Account | EndUser | None: return self._queries.actor(tenant_id=self.runtime.tenant_id, user_id=user_id) def _get_workflow(self, app_id: str, version: str) -> WorkflowSnapshot: + from repositories.workflow.definition_repository import workflow_snapshot + workflow = self._queries.workflow(tenant_id=self.runtime.tenant_id, app_id=app_id, version=version) return workflow_snapshot(workflow) diff --git a/api/tests/e2e_tests/containers/controllers/console/workspace/test_tool_provider.py b/api/tests/e2e_tests/containers/controllers/console/workspace/test_tool_provider.py index e23e897e2865fc..49ba386fa0e272 100644 --- a/api/tests/e2e_tests/containers/controllers/console/workspace/test_tool_provider.py +++ b/api/tests/e2e_tests/containers/controllers/console/workspace/test_tool_provider.py @@ -1,7 +1,8 @@ """Integration coverage for the console MCP provider HTTP endpoint.""" import json -from unittest.mock import MagicMock, patch +from types import SimpleNamespace +from unittest.mock import ANY, call, patch import pytest from flask import Flask @@ -9,6 +10,7 @@ from sqlalchemy.orm import Session from core.tools.entities.api_entities import ToolProviderApiEntity +from services.tools.mcp_tools_manage_service import ReconnectResult from tests.e2e_tests.containers.controllers.console.helpers import ( authenticate_console_client, create_console_account_and_tenant, @@ -31,7 +33,7 @@ def _tool_payload() -> dict[str, object]: } -def _provider_entity() -> ToolProviderApiEntity: +def _provider_entity(*, tools: list[dict[str, object]] | None = None) -> ToolProviderApiEntity: return ToolProviderApiEntity.model_validate( { "id": "provider-1", @@ -48,7 +50,7 @@ def _provider_entity() -> ToolProviderApiEntity: "allow_delete": True, "plugin_id": "langgenius/provider", "plugin_unique_identifier": "langgenius/provider:1.0.0", - "tools": [_tool_payload()], + "tools": tools or [], "labels": ["utilities"], "server_url": "", "updated_at": 1710000000, @@ -73,20 +75,19 @@ def test_create_mcp_provider_populates_tools( client: FlaskClient, db_session_with_containers: Session, ) -> None: - account, _tenant = create_console_account_and_tenant(db_session_with_containers) + account, tenant = create_console_account_and_tenant(db_session_with_containers) headers = authenticate_console_client(client, account) - service = MagicMock() - service.create_provider.return_value = MagicMock(id="provider-1") - service.get_provider.return_value = MagicMock(id="provider-1", tenant_id="t1") + created_provider = _provider_entity() + connected_provider = _provider_entity(tools=[_tool_payload()]) + reconnect = ReconnectResult(authed=True, tools=json.dumps([_tool_payload()]), encrypted_credentials="{}") + db_provider = SimpleNamespace(authed=False, tools="[]") - with ( - patch("controllers.console.workspace.tool_providers.MCPToolManageService", return_value=service, autospec=True), - patch( - "services.tools.legacy_tools_transform_service.ToolTransformService.mcp_provider_to_user_provider", - return_value=_provider_entity(), - autospec=True, - ), - ): + with patch("controllers.console.workspace.tool_providers.MCPToolManageService", autospec=True) as service_cls: + service = service_cls.return_value + service.create_provider.return_value = "provider-1" + service.get_provider_by_id.return_value = db_provider + service_cls.provider_response.side_effect = [created_provider, connected_provider] + service_cls.reconnect_with_url.return_value = reconnect response = client.post( "/console/api/workspaces/current/tool-provider/mcp", data=json.dumps( @@ -106,6 +107,22 @@ def test_create_mcp_provider_populates_tools( content_type="application/json", ) + create_kwargs = service.create_provider.call_args.kwargs + assert create_kwargs["tenant_id"] == tenant.id + assert create_kwargs["user_id"] == account.id + assert create_kwargs["server_url"] == "http://example.com/mcp" + service_cls.provider_response.assert_has_calls( + [ + call(tenant_id=tenant.id, provider_id="provider-1", tool_providers=ANY), + call(tenant_id=tenant.id, provider_id="provider-1", tool_providers=ANY), + ] + ) + service_cls.reconnect_with_url.assert_called_once_with( + server_url="http://example.com/mcp", headers={}, timeout=5.0, sse_read_timeout=30.0 + ) + service.get_provider_by_id.assert_called_once_with(provider_id="provider-1", tenant_id=tenant.id) + assert db_provider.authed is True + assert db_provider.tools == reconnect.tools assert response.status_code == 200 body = response.get_json() assert body["id"] == "provider-1" diff --git a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py index 364b640d93fbc7..031a278085a746 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py @@ -7,8 +7,8 @@ import pytest from flask import Flask from werkzeug.datastructures import FileStorage -from werkzeug.exceptions import Forbidden +from controllers.common.errors import AccessDeniedError from controllers.console.workspace.plugin import ( ParserAsset, ParserAutoUpgradeChange, @@ -504,7 +504,7 @@ def test_plugin_category_list(self, app: Flask): language="zh_Hans", ) - def test_builtin_tool_providers_use_the_category_list_filters(self): + def test_builtin_tool_providers_use_the_category_list_filters(self, *, workflow_application): search_provider = ToolProviderApiEntity( id="search-provider", author="dify", @@ -527,7 +527,7 @@ def test_builtin_tool_providers_use_the_category_list_filters(self): ) with ( - patch("controllers.console.workspace.plugin.ToolManager.list_default_builtin_providers", return_value=[]), + patch.object(workflow_application.tools.tool_providers, "default_builtin", return_value=[]), patch( "controllers.console.workspace.plugin.ToolManager.list_hardcoded_providers", return_value=[MagicMock(), MagicMock()], @@ -721,7 +721,7 @@ def test_change_permission_forbidden(self, app: Flask): with ( app.test_request_context("/", json=payload), ): - with pytest.raises(Forbidden): + with pytest.raises(AccessDeniedError): method(api, ParserPermissionChange(), "t1", user) def test_change_permission_success(self, app: Flask): diff --git a/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py b/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py index f976d29dcf7df1..8913f4c8d2203e 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py @@ -4,6 +4,7 @@ import importlib from collections.abc import Callable, Iterator from contextlib import ExitStack, contextmanager +from dataclasses import dataclass from inspect import unwrap from types import FunctionType, ModuleType, SimpleNamespace from unittest.mock import MagicMock, create_autospec, patch @@ -13,8 +14,8 @@ from flask import Flask from flask.views import MethodView from sqlalchemy.orm import Session, scoped_session, sessionmaker -from werkzeug.exceptions import Forbidden +from controllers.common.errors import AccessDeniedError from controllers.common.rbac import RBACPermission, Workspace from controllers.console.datasets.rag_pipeline.datasource_auth import DatasourceAuthOauthCustomClient from controllers.console.workspace.tool_providers import ( @@ -64,6 +65,7 @@ from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_entities import ToolParameter from enums import DeploymentEdition +from extensions.application_services.tools import ToolServices, build_tool_services from models import Account as _tool_providers_Account from models import BuiltinToolProvider, Tenant, TenantAccountJoin from models.account import Account as _tool_provider_apis_Account @@ -276,6 +278,7 @@ def test_is_valid_url(self) -> None: assert not is_valid_url("not-a-url") assert not is_valid_url(None) # type: ignore[arg-type] + @pytest.mark.usefixtures("workflow_application") class TestToolProviderListApi: def test_get_success(self, app: Flask) -> None: api = ToolProviderListApi() @@ -297,6 +300,7 @@ def test_get_success(self, app: Flask) -> None: assert result[0]["tools"][0]["name"] == "ping" class TestBuiltinProviderApis: + @pytest.mark.usefixtures("workflow_application") def test_list_tools(self, app: Flask) -> None: api = ToolBuiltinProviderListToolsApi() method = unwrap(api.get) @@ -513,6 +517,7 @@ def test_remote_schema(self, app: Flask) -> None: ): assert method(api, "t", make_account()) == {"schema": openapi_schema} + @pytest.mark.usefixtures("workflow_application") def test_list_tools(self, app: Flask) -> None: api = ToolApiProviderListToolsApi() method = unwrap(api.get) @@ -569,6 +574,7 @@ def test_delete(self, app: Flask) -> None: req = ApiToolProviderDeletePayload(provider="p") assert method(api, req, "t", make_account())["result"] == "success" + @pytest.mark.usefixtures("workflow_application") def test_get(self, app: Flask) -> None: api = ToolApiProviderGetApi() method = unwrap(api.get) @@ -582,6 +588,7 @@ def test_get(self, app: Flask) -> None: ): assert method(api, "t", make_account())["schema"] == "{}" + @pytest.mark.usefixtures("workflow_application") class TestWorkflowApis: def test_create(self, app: Flask) -> None: api = ToolWorkflowProviderCreateApi() @@ -599,7 +606,7 @@ def test_create(self, app: Flask) -> None: with ( app.test_request_context("/", json=payload), patch( - "controllers.console.workspace.tool_providers.WorkflowToolManageService.create_workflow_tool", + "services.tools.workflow_tools_manage_service.WorkflowToolManageService.create_workflow_tool", return_value={"result": "success"}, ) as create_workflow_tool, ): @@ -623,7 +630,7 @@ def test_update_invalid(self, app: Flask) -> None: with ( app.test_request_context("/", json=payload), patch( - "controllers.console.workspace.tool_providers.WorkflowToolManageService.update_workflow_tool", + "services.tools.workflow_tools_manage_service.WorkflowToolManageService.update_workflow_tool", return_value={"result": "success"}, ) as update_workflow_tool, ): @@ -640,7 +647,7 @@ def test_delete(self, app: Flask) -> None: with ( app.test_request_context("/", json={"workflow_tool_id": "123e4567-e89b-12d3-a456-426614174000"}), patch( - "controllers.console.workspace.tool_providers.WorkflowToolManageService.delete_workflow_tool", + "services.tools.workflow_tools_manage_service.WorkflowToolManageService.delete_workflow_tool", return_value={"result": "success"}, ), ): @@ -663,6 +670,7 @@ def test_get_error(self, app: Flask) -> None: ): method(api, "t", make_account()) + @pytest.mark.usefixtures("workflow_application") class TestLists: def test_builtin_list(self, app: Flask) -> None: api = ToolBuiltinListApi() @@ -697,7 +705,7 @@ def test_workflow_list(self, app: Flask) -> None: with ( app.test_request_context("/"), patch( - "controllers.console.workspace.tool_providers.WorkflowToolManageService.list_tenant_workflow_tools", + "services.tools.workflow_tools_manage_service.WorkflowToolManageService.list_tenant_workflow_tools", return_value=[provider_entity(provider_id="workflow-1", provider_type="workflow")], ), ): @@ -717,6 +725,7 @@ def test_labels(self, app: Flask) -> None: ): assert method(api)[0]["name"] == "utilities" + @pytest.mark.usefixtures("workflow_application") class TestOAuth: def test_oauth_no_client(self, app: Flask) -> None: api = ToolPluginOAuthApi() @@ -729,7 +738,7 @@ def test_oauth_no_client(self, app: Flask) -> None: return_value=None, ), ): - with pytest.raises(Forbidden): + with pytest.raises(AccessDeniedError): method(api, "t", make_account(), "provider") def test_oauth_callback_no_cookie(self, app: Flask) -> None: @@ -737,7 +746,7 @@ def test_oauth_callback_no_cookie(self, app: Flask) -> None: method = unwrap(api.get) with app.test_request_context("/"): - with pytest.raises(Forbidden): + with pytest.raises(AccessDeniedError): method(api, "provider") class TestOAuthCustomClient: @@ -789,6 +798,12 @@ def test_delete_custom_client(self, app: Flask) -> None: builtins.MethodView = MethodView # type: ignore[attr-defined] +@dataclass(frozen=True) +class ProviderApplicationStub: + tools: ToolServices + credential_queries: CredentialQuery | CredentialQueryRepository + + _CONTROLLER_MODULE: ModuleType | None = None _WRAPS_MODULE: ModuleType | None = None @@ -846,8 +861,9 @@ def _bind_database_session(session: Session): patch("extensions.ext_database.db.session", database_session), patch( "controllers.console.workspace.tool_providers.application_services", - return_value=SimpleNamespace( - credential_queries=CredentialQueryRepository(session_factory=session_factory) + return_value=ProviderApplicationStub( + tools=build_tool_services(session_factory), + credential_queries=CredentialQueryRepository(session_factory=session_factory), ), ), ): @@ -863,7 +879,7 @@ def _mock_credential_encryption(controller_module: ModuleType): encrypter.mask_plugin_credentials.return_value = {"api_key": "[__HIDDEN__]"} with ( patch( - "services.tools.legacy_builtin_tools_manage_service.ToolManager.get_builtin_provider", + "services.tools.builtin_tools_manage_service.ToolManager.get_builtin_provider", return_value=MagicMock(), ), patch.object( @@ -1137,9 +1153,7 @@ def app() -> Flask: @staticmethod @pytest.fixture - def controller_module( - monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None], sqlite_session_factory - ): + def controller_module(monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None], workflow_application): """ Import the controller with auth decorators neutralized only during import. @@ -1177,13 +1191,11 @@ def _noop(func): login_module = importlib.import_module("libs.login") monkeypatch.setattr(login_module, "check_csrf_token", lambda *args, **kwargs: None) - monkeypatch.setattr( - module, - "application_services", - lambda: SimpleNamespace( - credential_queries=CredentialQueryRepository(session_factory=sqlite_session_factory) - ), + registry = ProviderApplicationStub( + tools=workflow_application.tools, + credential_queries=create_autospec(CredentialQuery, instance=True), ) + monkeypatch.setattr(module, "application_services", lambda: registry) return module @staticmethod @@ -1201,7 +1213,13 @@ def test_tool_provider_list_calls_service_with_query( response = controller_module.ToolProviderListApi().get() assert response == [expected_response] - service_mock.assert_called_once_with(user.id, "tenant-456", "builtin") + service_mock.assert_called_once_with( + user.id, + "tenant-456", + "builtin", + workflow_queries=controller_module.application_services().tools.workflow_queries, + tool_providers=controller_module.application_services().tools.tool_providers, + ) @staticmethod def test_builtin_provider_add_passes_payload( @@ -1437,7 +1455,12 @@ def test_api_provider_list_tools_get(app: Flask, controller_module, monkeypatch: resp = controller_module.ToolApiProviderListToolsApi().get() assert resp == [expected_response] - service_mock.assert_called_once_with(user.id, "tenant-11", "foo") + service_mock.assert_called_once_with( + user.id, + "tenant-11", + "foo", + tool_providers=controller_module.application_services().tools.tool_providers, + ) @staticmethod def test_api_provider_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch): @@ -1451,7 +1474,12 @@ def test_api_provider_get(app: Flask, controller_module, monkeypatch: pytest.Mon resp = controller_module.ToolApiProviderGetApi().get() assert resp == expected_response - service_mock.assert_called_once_with(user.id, "tenant-12", "foo") + service_mock.assert_called_once_with( + user.id, + "tenant-12", + "foo", + tool_providers=controller_module.application_services().tools.tool_providers, + ) @staticmethod def test_builtin_provider_credentials_schema_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch): @@ -1480,7 +1508,7 @@ def test_workflow_provider_get_by_tool(app: Flask, controller_module, monkeypatc service_payload, expected_response = _workflow_detail_response(controller_module) tool_service = MagicMock(return_value=service_payload) monkeypatch.setattr( - controller_module.WorkflowToolManageService, + controller_module.application_services().tools.workflows, "get_workflow_tool_by_tool_id", tool_service, ) @@ -1499,7 +1527,7 @@ def test_workflow_provider_get_by_app(app: Flask, controller_module, monkeypatch service_payload, expected_response = _workflow_detail_response(controller_module) service_mock = MagicMock(return_value=service_payload) monkeypatch.setattr( - controller_module.WorkflowToolManageService, + controller_module.application_services().tools.workflows, "get_workflow_tool_by_app_id", service_mock, ) @@ -1517,7 +1545,11 @@ def test_workflow_provider_list_tools(app: Flask, controller_module, monkeypatch _set_current_account(monkeypatch, controller_module, user, "tenant-wf3") service_payload, expected_response = _tool_response(controller_module, "workflow-tool") service_mock = MagicMock(return_value=[service_payload]) - monkeypatch.setattr(controller_module.WorkflowToolManageService, "list_single_workflow_tools", service_mock) + monkeypatch.setattr( + controller_module.application_services().tools.workflows, + "list_single_workflow_tools", + service_mock, + ) tool_id = "00000000-0000-0000-0000-000000000003" with app.test_request_context(f"/workflow/tools?workflow_tool_id={tool_id}"): @@ -1567,7 +1599,7 @@ def test_workflow_tools_list(app: Flask, controller_module, monkeypatch: pytest. provider, expected_response = _provider_entity_response(controller_module, "wf", "workflow") monkeypatch.setattr( - controller_module.WorkflowToolManageService, + controller_module.application_services().tools.workflows, "list_tenant_workflow_tools", MagicMock(return_value=[provider]), ) @@ -1726,7 +1758,7 @@ def unexpected_dispatch(*_args: object, **_kwargs: object) -> None: monkeypatch.setattr(controller_module.OAuthHandler, "get_credentials", unexpected_dispatch) api = controller_module.ToolOAuthCallback() with app.test_request_context("/", headers={"Cookie": "context_id=ctx-id"}): - with pytest.raises(Forbidden, match="Plugin changed"): + with pytest.raises(AccessDeniedError, match="Plugin changed"): unwrap(api.get)(api, provider=_OAUTH_PROVIDER_PATH) @staticmethod diff --git a/api/tests/unit_tests/core/mcp/test_mcp_client.py b/api/tests/unit_tests/core/mcp/test_mcp_client.py index 052d1637717275..6176feb13936fc 100644 --- a/api/tests/unit_tests/core/mcp/test_mcp_client.py +++ b/api/tests/unit_tests/core/mcp/test_mcp_client.py @@ -589,7 +589,7 @@ def test_init(self, provider): assert client.authorization_code == "initial-code" assert client._has_retried is False - @patch("services.tools.legacy_mcp_tools_manage_service.MCPToolManageService") + @patch("services.tools.mcp_tools_manage_service.MCPToolManageService") def test_handle_auth_error_success( self, mock_service_class, @@ -658,7 +658,7 @@ def test_handle_auth_error_already_retried(self, auth_client): assert exc_info.value == error - @patch("services.tools.legacy_mcp_tools_manage_service.MCPToolManageService") + @patch("services.tools.mcp_tools_manage_service.MCPToolManageService") def test_handle_auth_error_no_token( self, mock_service_class, @@ -681,7 +681,7 @@ def test_handle_auth_error_no_token( assert "Authentication failed - no token received" in str(exc_info.value) - @patch("services.tools.legacy_mcp_tools_manage_service.MCPToolManageService") + @patch("services.tools.mcp_tools_manage_service.MCPToolManageService") def test_handle_auth_error_generic_exception( self, mock_service_class, @@ -713,7 +713,7 @@ def execute_statement(*_args, **_kwargs): assert "Authentication retry failed: DB error" in str(exc_info.value) - @patch("services.tools.legacy_mcp_tools_manage_service.MCPToolManageService") + @patch("services.tools.mcp_tools_manage_service.MCPToolManageService") def test_handle_auth_error_mcp_auth_error_propagation( self, mock_service_class, diff --git a/api/tests/unit_tests/core/tools/custom_tool/test_provider.py b/api/tests/unit_tests/core/tools/custom_tool/test_provider.py index ffb7a2a8bb9263..b9dccab63a6b59 100644 --- a/api/tests/unit_tests/core/tools/custom_tool/test_provider.py +++ b/api/tests/unit_tests/core/tools/custom_tool/test_provider.py @@ -1,127 +1,69 @@ -"""Tests for custom API tool providers with persisted provider lookup state.""" - -from __future__ import annotations - -import json -from dataclasses import dataclass -from uuid import uuid4 +"""API provider construction consumes already loaded configuration.""" import pytest -from sqlalchemy import delete -from sqlalchemy.orm import Session -from core.tools.custom_tool import provider as provider_module -from core.tools.custom_tool.provider import ApiToolProviderController from core.tools.custom_tool.tool import ApiTool from core.tools.entities.tool_bundle import ApiToolBundle -from core.tools.entities.tool_entities import ApiProviderAuthType, ApiProviderSchemaType, ToolProviderType -from models.tools import ApiToolProvider - - -@dataclass(frozen=True) -class _Database: - session: Session +from core.tools.entities.tool_entities import ApiProviderAuthType, ToolProviderType +from services.tools.api.contracts import ApiToolProviderRecord +from services.tools.api.provider import ApiToolProviderController -def _tool_bundle() -> ApiToolBundle: - return ApiToolBundle( - server_url="https://api.example.com/items", - method="GET", - summary="List items", - operation_id="list_items", - parameters=[], - author="author", - openapi={"parameters": []}, - ) - - -def _db_provider() -> ApiToolProvider: - provider = ApiToolProvider( - name="provider-a", - icon="icon.svg", +def provider_record() -> ApiToolProviderRecord: + return ApiToolProviderRecord( + schema_type="openapi", schema="{}", - schema_type_str=ApiProviderSchemaType.OPENAPI, - user_id="", + privacy_policy="", + custom_disclaimer="", + id="provider-id", tenant_id="tenant-1", - description="desc", - tools_str=json.dumps([_tool_bundle().model_dump(mode="json")]), - credentials_str='{"auth_type":"none"}', - ) - provider.id = "provider-id" - return provider - - -def _persist_provider(session: Session, *, tenant_id: str, name: str = "provider-a") -> ApiToolProvider: - bundle = _tool_bundle() - provider = ApiToolProvider( - name=name, + name="provider-a", icon="icon.svg", - schema="{}", - schema_type_str=ApiProviderSchemaType.OPENAPI, - user_id=str(uuid4()), - tenant_id=tenant_id, description="desc", - tools_str=json.dumps([bundle.model_dump(mode="json")]), - credentials_str='{"auth_type":"none"}', + author="Alice", + credentials={"auth_type": "none"}, + tools=[ + ApiToolBundle( + server_url="https://api.example.com/items", + method="GET", + summary="List items", + operation_id="list_items", + parameters=[], + author="Alice", + openapi={"parameters": []}, + ) + ], ) - session.add(provider) - session.commit() - return provider -def test_api_tool_provider_from_db_and_parse_tool_bundle(sqlite_session: Session) -> None: - controller = ApiToolProviderController.from_db( - _db_provider(), ApiProviderAuthType.API_KEY_HEADER, session=sqlite_session - ) +@pytest.mark.parametrize( + ("auth_type", "credential"), + [ + (ApiProviderAuthType.API_KEY_HEADER, "api_key_header"), + (ApiProviderAuthType.API_KEY_QUERY, "api_key_query_param"), + (ApiProviderAuthType.NONE, "auth_type"), + ], +) +def test_provider_constructs_auth_schema_and_tools_from_loaded_record( + auth_type: ApiProviderAuthType, credential: str +) -> None: + provider = provider_record() + controller = ApiToolProviderController.from_provider(provider, auth_type, author=provider.author) assert controller.provider_type == ToolProviderType.API - assert any(c.name == "api_key_value" for c in controller.entity.credentials_schema) - - tool = controller._parse_tool_bundle(_db_provider().tools[0]) + assert controller.entity.identity.author == "Alice" + assert any(item.name == credential for item in controller.entity.credentials_schema) + tool = controller.get_tool("list_items") assert isinstance(tool, ApiTool) - assert tool.entity.identity.provider == "provider-id" - - -def test_api_tool_provider_from_db_query_auth_and_none_auth(sqlite_session: Session) -> None: - query_controller = ApiToolProviderController.from_db( - _db_provider(), ApiProviderAuthType.API_KEY_QUERY, session=sqlite_session - ) - assert any(c.name == "api_key_query_param" for c in query_controller.entity.credentials_schema) - - none_controller = ApiToolProviderController.from_db( - _db_provider(), ApiProviderAuthType.NONE, session=sqlite_session - ) - assert [c.name for c in none_controller.entity.credentials_schema] == ["auth_type"] - - -def test_api_tool_provider_load_get_tools_and_get_tool( - monkeypatch: pytest.MonkeyPatch, sqlite_session: Session -) -> None: - controller = ApiToolProviderController.from_db(_db_provider(), ApiProviderAuthType.NONE, session=sqlite_session) - loaded = controller.load_bundled_tools(_db_provider().tools) - assert len(loaded) == 1 - - assert isinstance(controller.get_tool("list_items"), ApiTool) - + assert tool.entity.identity.provider == provider.id + assert controller.get_tools(provider.tenant_id) == [tool] with pytest.raises(ValueError, match="not found"): controller.get_tool("missing") - # Return cached tools without querying database. - cached = controller.get_tools("tenant-1") - assert len(cached) == 1 - # Force DB fetch branch. - controller.tools = [] - tenant_id = str(uuid4()) - provider_with_tools = _persist_provider(sqlite_session, tenant_id=tenant_id) - _persist_provider(sqlite_session, tenant_id=str(uuid4())) - controller.tenant_id = tenant_id - monkeypatch.setattr(provider_module, "db", _Database(session=sqlite_session)) - - tools = controller.get_tools(tenant_id) - assert len(tools) == 1 - assert tools[0].entity.identity.provider == controller.provider_id - - sqlite_session.execute(delete(ApiToolProvider).where(ApiToolProvider.id == provider_with_tools.id)) - sqlite_session.commit() - controller.tools = [] - assert controller.get_tools(tenant_id) == [] +def test_empty_tools_do_not_trigger_implicit_provider_lookup() -> None: + provider = provider_record() + provider.tools.clear() + controller = ApiToolProviderController.from_provider(provider, ApiProviderAuthType.NONE, author=provider.author) + assert controller.get_tools(provider.tenant_id) == [] + with pytest.raises(ValueError, match="not found"): + controller.get_tool("list_items") diff --git a/api/tests/unit_tests/core/tools/test_tool_label_manager.py b/api/tests/unit_tests/core/tools/test_tool_label_manager.py index 9543f49b92f3e2..b98cdd2e421803 100644 --- a/api/tests/unit_tests/core/tools/test_tool_label_manager.py +++ b/api/tests/unit_tests/core/tools/test_tool_label_manager.py @@ -1,34 +1,12 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any, override -from unittest.mock import PropertyMock, patch - import pytest from sqlalchemy import select -from sqlalchemy.engine import Engine from sqlalchemy.orm import Session -import core.tools.tool_label_manager as tool_label_manager_module -from core.tools.builtin_tool.provider import BuiltinToolProviderController -from core.tools.custom_tool.provider import ApiToolProviderController -from core.tools.tool_label_manager import ToolLabelManager -from core.tools.workflow_as_tool.provider import WorkflowToolProviderController from models.tools import ToolLabelBinding - - -@dataclass(eq=False, repr=False) -class _DatabaseBinding: - """Expose the SQLite engine to code that owns its session lifecycle.""" - - engine: Engine - - -# Create a mock class for testing abstract/base classes -class _ConcreteBuiltinToolProviderController(BuiltinToolProviderController): - @override - def _validate_credentials(self, user_id: str, credentials: dict[str, Any]): - return None +from services.tools.api.provider import ApiToolProviderController +from services.tools.tool_label_manager import ToolLabelManager # Factory function to create a "lightweight" controller for testing @@ -38,21 +16,15 @@ def _api_controller(provider_id: str = "api-1") -> ApiToolProviderController: return controller -def _workflow_controller(provider_id: str = "wf-1") -> WorkflowToolProviderController: - controller = object.__new__(WorkflowToolProviderController) - controller.provider_id = provider_id - return controller - - # Test pure logic: filtering and deduplication -def test_tool_label_manager_filter_tool_labels(): +def test_tool_label_manager_filter_tool_labels() -> None: filtered = ToolLabelManager.filter_tool_labels(["search", "search", "invalid", "news"]) assert set(filtered) == {"search", "news"} assert len(filtered) == 2 @pytest.mark.parametrize("sqlite_session", [(ToolLabelBinding,)], indirect=True) -def test_tool_label_manager_update_tool_labels_db(sqlite_session: Session): +def test_tool_label_manager_update_tool_labels_db(sqlite_session: Session) -> None: """ Test the database update logic for tool labels. Focus: Verify that labels are filtered, de-duplicated, and safely handled within a database session. @@ -77,84 +49,6 @@ def test_tool_label_manager_update_tool_labels_db(sqlite_session: Session): # Test error handling -def test_tool_label_manager_update_tool_labels_unsupported(): - with pytest.raises(ValueError, match="Unsupported tool type"): - ToolLabelManager.update_tool_labels(object(), ["search"]) # type: ignore[arg-type] - - -# Test retrieval logic -@pytest.mark.parametrize("sqlite_session", [(ToolLabelBinding,)], indirect=True) -def test_tool_label_manager_get_tool_labels_for_builtin_and_db( - monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session -): - # Mocking a property (@property) using PropertyMock - with patch.object( - _ConcreteBuiltinToolProviderController, - "tool_labels", - new_callable=PropertyMock, - return_value=["search", "news"], - ): - builtin = object.__new__(_ConcreteBuiltinToolProviderController) - assert ToolLabelManager.get_tool_labels(builtin) == ["search", "news"] - - api = _api_controller("api-1") - sqlite_session.add_all( - [ - ToolLabelBinding(tool_id=api.provider_id, tool_type=api.provider_type, label_name="search"), - ToolLabelBinding(tool_id=api.provider_id, tool_type=api.provider_type, label_name="news"), - ] - ) - sqlite_session.commit() - monkeypatch.setattr(tool_label_manager_module, "db", _DatabaseBinding(sqlite_engine)) - - labels = ToolLabelManager.get_tool_labels(api) - assert set(labels) == {"search", "news"} - - -def test_tool_label_manager_get_tool_labels_unsupported(): - """ - Negative Test: Ensure get_tool_labels raises ValueError for unsupported controller types. - This protects the internal API contract against accidental regressions during refactoring. - """ - # Passing a generic object() which doesn't match Api, Workflow, or Builtin controllers. - with pytest.raises(ValueError, match="Unsupported tool type"): - ToolLabelManager.get_tool_labels(object()) # type: ignore[arg-type] - - -# Test batch processing and mapping -@pytest.mark.parametrize("sqlite_session", [(ToolLabelBinding,)], indirect=True) -def test_tool_label_manager_get_tools_labels_batch( - monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session -): - assert ToolLabelManager.get_tools_labels([]) == {} - - api = _api_controller("api-1") - wf = _workflow_controller("wf-1") - - sqlite_session.add_all( - [ - ToolLabelBinding(tool_id=api.provider_id, tool_type=api.provider_type, label_name="search"), - ToolLabelBinding(tool_id=api.provider_id, tool_type=api.provider_type, label_name="news"), - ToolLabelBinding(tool_id=wf.provider_id, tool_type=wf.provider_type, label_name="utilities"), - ] - ) - sqlite_session.commit() - monkeypatch.setattr(tool_label_manager_module, "db", _DatabaseBinding(sqlite_engine)) - - labels = ToolLabelManager.get_tools_labels([api, wf]) - - assert labels.keys() == {"api-1", "wf-1"} - assert set(labels["api-1"]) == {"search", "news"} - assert labels["wf-1"] == ["utilities"] - - -def test_tool_label_manager_get_tools_labels_unsupported(): - """ - Negative Test: Ensure get_tools_labels raises ValueError if the list contains - unsupported controller types, even alongside valid ones. - """ - api = _api_controller("api-1") - - # Passing a list with one valid controller and one invalid object() +def test_tool_label_manager_update_tool_labels_unsupported(sqlite_session: Session) -> None: with pytest.raises(ValueError, match="Unsupported tool type"): - ToolLabelManager.get_tools_labels([api, object()]) # type: ignore[list-item] + ToolLabelManager.update_tool_labels(object(), ["search"], session=sqlite_session) # type: ignore[arg-type] diff --git a/api/tests/unit_tests/core/tools/test_tool_manager.py b/api/tests/unit_tests/core/tools/test_tool_manager.py index a8b891f9d6a9ca..75ac174f29fcd9 100644 --- a/api/tests/unit_tests/core/tools/test_tool_manager.py +++ b/api/tests/unit_tests/core/tools/test_tool_manager.py @@ -6,15 +6,13 @@ import threading from collections.abc import Callable, Iterator from dataclasses import dataclass -from datetime import datetime from types import SimpleNamespace from typing import Any from unittest.mock import Mock, patch import pytest -from sqlalchemy import event from sqlalchemy.engine import Engine -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from core.app.entities.app_invoke_entities import InvokeFrom from core.entities.provider_entities import BasicProviderConfig, ProviderConfigType @@ -36,13 +34,15 @@ ) from core.tools.errors import ToolProviderCredentialValidationError, ToolProviderNotFoundError from core.tools.plugin_tool.provider import PluginToolProviderController -from core.tools.tool_manager import ToolManager -from core.tools.workflow_as_tool.provider import WorkflowToolProviderController -from core.tools.workflow_as_tool.tool import WorkflowTool from graphon.runtime import VariablePool +from models.account import Account from models.base import TypeBase from models.tools import ApiToolProvider, BuiltinToolProvider, WorkflowToolProvider +from repositories.tools.provider_repository import ToolProviderRepository from services.plugin.dispatch_identity import PluginOAuthClientConfig +from services.tools.tool_manager import ToolManager +from services.tools.workflow.provider import WorkflowToolProviderController +from services.tools.workflow.tool import WorkflowTool class _CallableSessionProxy: @@ -91,13 +91,18 @@ def tool_database(sqlite_engine: Engine) -> Iterator[_ToolDatabase]: tables = [ TypeBase.metadata.tables[model.__tablename__] - for model in (BuiltinToolProvider, ApiToolProvider, WorkflowToolProvider) + for model in (Account, BuiltinToolProvider, ApiToolProvider, WorkflowToolProvider) ] TypeBase.metadata.create_all(sqlite_engine, tables=tables) with Session(sqlite_engine, expire_on_commit=False) as session: yield _ToolDatabase(engine=sqlite_engine, session=_CallableSessionProxy(session)) +@pytest.fixture +def tool_providers(tool_database): + return ToolProviderRepository(sessionmaker(tool_database.engine, expire_on_commit=False)) + + def _builtin_provider( *, provider_id: str, @@ -180,70 +185,6 @@ def set(self, value: Any): self._is_set = True -def _setup_list_providers_from_api_mocks( - monkeypatch, - *, - tool_database: _ToolDatabase, - hardcoded_controller: SimpleNamespace, - plugin_controller: PluginToolProviderController, - api_controller: SimpleNamespace, - workflow_controller: SimpleNamespace, -): - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) - monkeypatch.setattr( - "core.tools.tool_manager.dify_config", - SimpleNamespace( - SQLALCHEMY_DATABASE_URI_SCHEME="mysql", - POSITION_TOOL_INCLUDES_SET=None, - POSITION_TOOL_EXCLUDES_SET=None, - ), - ) - monkeypatch.setattr( - ToolManager, - "list_builtin_providers", - Mock(return_value=[hardcoded_controller, plugin_controller]), - ) - monkeypatch.setattr( - ToolManager, - "list_default_builtin_providers", - Mock(return_value=[SimpleNamespace(provider="hardcoded")]), - ) - monkeypatch.setattr("core.tools.tool_manager.is_filtered", lambda *args, **kwargs: False) - monkeypatch.setattr( - "core.tools.tool_manager.ToolTransformService.builtin_provider_to_user_provider", - lambda **kwargs: SimpleNamespace(name=kwargs["provider_controller"].entity.identity.name), - ) - monkeypatch.setattr( - "core.tools.tool_manager.ToolTransformService.api_provider_to_controller", - Mock(side_effect=[api_controller, RuntimeError("invalid")]), - ) - monkeypatch.setattr( - "core.tools.tool_manager.ToolTransformService.api_provider_to_user_provider", - Mock(return_value=SimpleNamespace(name="api-provider")), - ) - monkeypatch.setattr( - "core.tools.tool_manager.ToolTransformService.workflow_provider_to_controller", - Mock(side_effect=[workflow_controller, RuntimeError("deleted app")]), - ) - monkeypatch.setattr( - "core.tools.tool_manager.ToolTransformService.workflow_provider_to_user_provider", - Mock(return_value=SimpleNamespace(name="workflow-provider")), - ) - monkeypatch.setattr( - "core.tools.tool_manager.ToolLabelManager.get_tools_labels", - Mock( - side_effect=[ - {api_controller.provider_id: ["search"]}, - {workflow_controller.provider_id: ["utility"]}, - ] - ), - ) - mock_mcp_service = Mock() - mock_mcp_service.list_providers.return_value = [SimpleNamespace(name="mcp-provider")] - monkeypatch.setattr("core.tools.tool_manager.MCPToolManageService", Mock(return_value=mock_mcp_service)) - monkeypatch.setattr("core.tools.tool_manager.BuiltinToolProviderSort.sort", lambda providers: providers) - - @pytest.fixture(autouse=True) def _reset_tool_manager_state(): from extensions.application_services.tools import resolve_tool_credentials @@ -294,12 +235,12 @@ def test_get_plugin_provider_uses_context_cache(): lock_context.set(threading.Lock()) provider_entity = SimpleNamespace(declaration=Mock(), plugin_id="pid", plugin_unique_identifier="uid") - with patch("core.tools.tool_manager.contexts.plugin_tool_providers", provider_context): - with patch("core.tools.tool_manager.contexts.plugin_tool_providers_lock", lock_context): - with patch("core.tools.tool_manager.PluginToolManager") as mock_manager_cls: + with patch("services.tools.tool_manager.contexts.plugin_tool_providers", provider_context): + with patch("services.tools.tool_manager.contexts.plugin_tool_providers_lock", lock_context): + with patch("services.tools.tool_manager.PluginToolManager") as mock_manager_cls: mock_manager_cls.return_value.fetch_tool_provider.return_value = provider_entity controller = SimpleNamespace(name="controller") - with patch("core.tools.tool_manager.PluginToolProviderController", return_value=controller): + with patch("services.tools.tool_manager.PluginToolProviderController", return_value=controller): built = ToolManager.get_plugin_provider("provider-a", "tenant-1") cached = ToolManager.get_plugin_provider("provider-a", "tenant-1") @@ -313,9 +254,9 @@ def test_get_plugin_provider_raises_when_provider_missing(): lock_context = _SimpleContextVar() lock_context.set(threading.Lock()) - with patch("core.tools.tool_manager.contexts.plugin_tool_providers", provider_context): - with patch("core.tools.tool_manager.contexts.plugin_tool_providers_lock", lock_context): - with patch("core.tools.tool_manager.PluginToolManager") as mock_manager_cls: + with patch("services.tools.tool_manager.contexts.plugin_tool_providers", provider_context): + with patch("services.tools.tool_manager.contexts.plugin_tool_providers_lock", lock_context): + with patch("services.tools.tool_manager.PluginToolManager") as mock_manager_cls: mock_manager_cls.return_value.fetch_tool_provider.return_value = None with pytest.raises(ToolProviderNotFoundError, match="plugin provider provider-a not found"): ToolManager.get_plugin_provider("provider-a", "tenant-1") @@ -346,15 +287,15 @@ def test_get_plugin_provider_translates_plugin_not_found_to_domain_error(daemon_ lock_context = _SimpleContextVar() lock_context.set(threading.Lock()) - with patch("core.tools.tool_manager.contexts.plugin_tool_providers", provider_context): - with patch("core.tools.tool_manager.contexts.plugin_tool_providers_lock", lock_context): - with patch("core.tools.tool_manager.PluginToolManager") as mock_manager_cls: + with patch("services.tools.tool_manager.contexts.plugin_tool_providers", provider_context): + with patch("services.tools.tool_manager.contexts.plugin_tool_providers_lock", lock_context): + with patch("services.tools.tool_manager.PluginToolManager") as mock_manager_cls: mock_manager_cls.return_value.fetch_tool_provider.side_effect = daemon_error with pytest.raises(ToolProviderNotFoundError, match="plugin provider provider-a not found"): ToolManager.get_plugin_provider("provider-a", "tenant-1") -def test_get_tool_runtime_builtin_without_credentials(): +def test_get_tool_runtime_builtin_without_credentials(*, tool_providers, workflow_queries): tool = Mock() tool.fork_tool_runtime.return_value = "runtime-tool" controller = SimpleNamespace(get_tool=Mock(return_value=tool), need_credentials=False) @@ -365,6 +306,8 @@ def test_get_tool_runtime_builtin_without_credentials(): provider_id="time", tool_name="current_time", tenant_id="tenant-1", + tool_providers=tool_providers, + workflow_queries=workflow_queries, ) assert result == "runtime-tool" @@ -373,7 +316,7 @@ def test_get_tool_runtime_builtin_without_credentials(): assert runtime.credentials == {} -def test_get_tool_runtime_builtin_missing_tool_raises(): +def test_get_tool_runtime_builtin_missing_tool_raises(*, tool_providers, workflow_queries): controller = SimpleNamespace(get_tool=Mock(return_value=None), need_credentials=False) with patch.object(ToolManager, "get_builtin_provider", return_value=controller): @@ -383,11 +326,13 @@ def test_get_tool_runtime_builtin_missing_tool_raises(): provider_id="time", tool_name="missing", tenant_id="tenant-1", + tool_providers=tool_providers, + workflow_queries=workflow_queries, ) def test_get_tool_runtime_builtin_with_credentials_decrypts_and_forks( - monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase + tool_database: _ToolDatabase, *, tool_providers, workflow_queries ): tool = Mock() tool.fork_tool_runtime.return_value = "runtime-tool" @@ -403,7 +348,6 @@ def test_get_tool_runtime_builtin_with_credentials_decrypts_and_forks( ) tool_database.session.add(builtin_provider) tool_database.session.commit() - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) with patch.object(ToolManager, "get_builtin_provider", return_value=controller): with patch("core.helper.credential_utils.check_credential_policy_compliance"): @@ -418,6 +362,8 @@ def test_get_tool_runtime_builtin_with_credentials_decrypts_and_forks( provider_id="time", tool_name="weekday", tenant_id=tenant_id, + tool_providers=tool_providers, + workflow_queries=workflow_queries, ) assert result == "runtime-tool" @@ -446,6 +392,9 @@ def test_get_tool_runtime_builtin_refreshes_expired_oauth_credentials( tool_database: _ToolDatabase, client_credentials: dict[str, str] | None, credential_selection: str, + *, + tool_providers, + workflow_queries, ): tool = Mock() tool.fork_tool_runtime.return_value = "runtime-tool" @@ -490,7 +439,6 @@ def test_get_tool_runtime_builtin_refreshes_expired_oauth_credentials( tool_database.session.add(builtin_provider) tool_database.session.commit() - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) encrypter = Mock() encrypter.decrypt.return_value = {"token": "old"} encrypter.encrypt.return_value = {"token": "encrypted"} @@ -504,6 +452,8 @@ def test_get_tool_runtime_builtin_refreshes_expired_oauth_credentials( provider_id="time", tool_name="weekday", tenant_id=tenant_id, + tool_providers=tool_providers, + workflow_queries=workflow_queries, credential_id=provider_id if credential_selection == "plugin-explicit" else None, ) @@ -519,10 +469,11 @@ def test_get_tool_runtime_builtin_refreshes_expired_oauth_credentials( assert refresh.call_args.kwargs["system_credentials"] == (client_credentials or {}) expected_identifier = identifier if plugin_provider or client_credentials is not None else None assert refresh.call_args.kwargs["plugin_unique_identifier"] == expected_identifier + mock_get_oauth_client.assert_called_once() def test_get_tool_runtime_builtin_maps_oauth_refresh_failure_to_credential_error( - monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase + tool_database: _ToolDatabase, *, tool_providers, workflow_queries ): tool = Mock() controller = SimpleNamespace( @@ -539,7 +490,6 @@ def test_get_tool_runtime_builtin_maps_oauth_refresh_failure_to_credential_error ) tool_database.session.add(builtin_provider) tool_database.session.commit() - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) encrypter = Mock() encrypter.decrypt.return_value = {"token": "expired"} @@ -562,18 +512,17 @@ def test_get_tool_runtime_builtin_maps_oauth_refresh_failure_to_credential_error provider_id="time", tool_name="weekday", tenant_id=tenant_id, + tool_providers=tool_providers, + workflow_queries=workflow_queries, ) -def test_get_tool_runtime_builtin_plugin_credential_deleted_raises( - monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase -): +def test_get_tool_runtime_builtin_plugin_credential_deleted_raises(*, tool_providers, workflow_queries): plugin_controller = object.__new__(PluginToolProviderController) plugin_controller.entity = SimpleNamespace(credentials_schema=[{"name": "k"}], oauth_schema=None) plugin_controller.get_tool = Mock(return_value=Mock()) plugin_controller.get_credentials_schema_by_type = Mock(return_value=[]) - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) with patch.object(ToolManager, "get_builtin_provider", return_value=plugin_controller): with pytest.raises(ToolProviderCredentialValidationError, match="credential .* has been deleted"): ToolManager.get_tool_runtime( @@ -582,18 +531,17 @@ def test_get_tool_runtime_builtin_plugin_credential_deleted_raises( tool_name="weekday", tenant_id="00000000-0000-0000-0000-000000000001", credential_id="00000000-0000-0000-0000-000000000002", + tool_providers=tool_providers, + workflow_queries=workflow_queries, ) -def test_get_tool_runtime_builtin_plugin_without_workspace_credential_raises( - monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase -): +def test_get_tool_runtime_builtin_plugin_without_workspace_credential_raises(*, tool_providers, workflow_queries): plugin_controller = object.__new__(PluginToolProviderController) plugin_controller.entity = SimpleNamespace(credentials_schema=[{"name": "k"}], oauth_schema=None) plugin_controller.get_tool = Mock(return_value=Mock()) plugin_controller.get_credentials_schema_by_type = Mock(return_value=[]) - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) with patch.object(ToolManager, "get_builtin_provider", return_value=plugin_controller): with pytest.raises(ToolProviderCredentialValidationError, match="No workspace credential is configured"): ToolManager.get_tool_runtime( @@ -601,18 +549,17 @@ def test_get_tool_runtime_builtin_plugin_without_workspace_credential_raises( provider_id="langgenius/dify-gmail/dify-gmail", tool_name="send_draft", tenant_id="00000000-0000-0000-0000-000000000001", + tool_providers=tool_providers, + workflow_queries=workflow_queries, ) -def test_get_tool_runtime_hardcoded_provider_without_credential_raises( - monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase -): +def test_get_tool_runtime_hardcoded_provider_without_credential_raises(*, tool_providers, workflow_queries): controller = SimpleNamespace( get_tool=Mock(return_value=Mock()), need_credentials=True, ) - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) with patch.object(ToolManager, "get_builtin_provider", return_value=controller): with pytest.raises(ToolProviderCredentialValidationError, match="No credential is configured"): ToolManager.get_tool_runtime( @@ -620,10 +567,12 @@ def test_get_tool_runtime_hardcoded_provider_without_credential_raises( provider_id="legacy-provider", tool_name="legacy-tool", tenant_id="00000000-0000-0000-0000-000000000001", + tool_providers=tool_providers, + workflow_queries=workflow_queries, ) -def test_get_tool_runtime_api_path(): +def test_get_tool_runtime_api_path(*, tool_providers, workflow_queries): api_tool = Mock() api_tool.fork_tool_runtime.return_value = "api-runtime" api_provider = Mock() @@ -632,25 +581,28 @@ def test_get_tool_runtime_api_path(): with patch.object(ToolManager, "get_api_provider_controller", return_value=(api_provider, {"c": "enc"})): encrypter = Mock() encrypter.decrypt.return_value = {"c": "dec"} - with patch("core.tools.tool_manager.create_tool_provider_encrypter", return_value=(encrypter, Mock())): + with patch("services.tools.tool_manager.create_tool_provider_encrypter", return_value=(encrypter, Mock())): assert ( ToolManager.get_tool_runtime( provider_type=ToolProviderType.API, provider_id="api-1", tool_name="search", tenant_id="tenant-1", + tool_providers=tool_providers, + workflow_queries=workflow_queries, ) == "api-runtime" ) -def test_get_tool_runtime_workflow_path(monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase): +def test_get_tool_runtime_workflow_path( + tool_database: _ToolDatabase, *, tool_providers, workflow_queries, workflow_runtime +): tenant_id = "00000000-0000-0000-0000-000000000001" provider_id = "00000000-0000-0000-0000-000000000002" workflow_provider = _workflow_provider(provider_id=provider_id, tenant_id=tenant_id) tool_database.session.add(workflow_provider) tool_database.session.commit() - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) workflow_tool = WorkflowTool( workflow_app_id=workflow_provider.app_id, workflow_as_tool_id=provider_id, @@ -661,6 +613,9 @@ def test_get_tool_runtime_workflow_path(monkeypatch: pytest.MonkeyPatch, tool_da identity=ToolIdentity(author="test", name="wf", label=I18nObject(en_US="Workflow"), provider=provider_id), ), runtime=ToolRuntime(tenant_id=tenant_id), + queries=workflow_queries, + draft_variable_saver=None, + workflow_runtime=workflow_runtime, ) workflow_controller = WorkflowToolProviderController( entity=ToolProviderEntity( @@ -673,10 +628,13 @@ def test_get_tool_runtime_workflow_path(monkeypatch: pytest.MonkeyPatch, tool_da ), ), provider_id=provider_id, + queries=workflow_queries, + draft_variable_saver=None, + workflow_runtime=workflow_runtime, ) workflow_controller.tools = [workflow_tool] with patch( - "core.tools.tool_manager.ToolTransformService.workflow_provider_to_controller", + "services.tools.tool_manager.ToolTransformService.workflow_provider_to_controller", return_value=workflow_controller, ): runtime_tool = ToolManager.get_tool_runtime( @@ -684,6 +642,9 @@ def test_get_tool_runtime_workflow_path(monkeypatch: pytest.MonkeyPatch, tool_da provider_id=provider_id, tool_name="wf", tenant_id=tenant_id, + tool_providers=tool_providers, + workflow_queries=workflow_queries, + workflow_runtime=workflow_runtime, ) assert isinstance(runtime_tool, WorkflowTool) assert runtime_tool is not workflow_tool @@ -694,54 +655,76 @@ def test_get_tool_runtime_workflow_path(monkeypatch: pytest.MonkeyPatch, tool_da invoke_from=InvokeFrom.DEBUGGER, tool_invoke_from=ToolInvokeFrom.AGENT, ) - assert runtime_tool.execution_driver is None + assert runtime_tool._workflow_runtime is workflow_runtime + assert runtime_tool._workflow_runtime.execution_driver is workflow_runtime.execution_driver -def test_get_tool_runtime_plugin_path(): +def test_get_tool_runtime_plugin_path(*, tool_providers, workflow_queries): + tool = SimpleNamespace(runtime=ToolRuntime(tenant_id="tenant-1")) with patch.object( ToolManager, "get_plugin_provider", - return_value=SimpleNamespace(get_tool=lambda _: "plugin-tool"), + return_value=SimpleNamespace(get_tool=lambda _: tool), ): - assert ( - ToolManager.get_tool_runtime( - provider_type=ToolProviderType.PLUGIN, - provider_id="plugin-1", - tool_name="p", - tenant_id="tenant-1", - ) - == "plugin-tool" + result = ToolManager.get_tool_runtime( + provider_type=ToolProviderType.PLUGIN, + provider_id="plugin-1", + tool_name="p", + tenant_id="tenant-1", + user_id="caller-1", + invoke_from=InvokeFrom.SERVICE_API, + tool_invoke_from=ToolInvokeFrom.WORKFLOW, + tool_providers=tool_providers, + workflow_queries=workflow_queries, ) + assert result is tool + assert tool.runtime.tenant_id == "tenant-1" + assert tool.runtime.user_id == "caller-1" + assert tool.runtime.invoke_from == InvokeFrom.SERVICE_API + assert tool.runtime.tool_invoke_from == ToolInvokeFrom.WORKFLOW + -def test_get_tool_runtime_mcp_path(): +def test_get_tool_runtime_mcp_path(*, tool_providers, workflow_queries): + tool = SimpleNamespace(runtime=ToolRuntime(tenant_id="tenant-1")) with patch.object( ToolManager, "get_mcp_provider_controller", - return_value=SimpleNamespace(get_tool=lambda _: "mcp-tool"), + return_value=SimpleNamespace(get_tool=lambda _: tool), ): - assert ( - ToolManager.get_tool_runtime( - provider_type=ToolProviderType.MCP, - provider_id="mcp-1", - tool_name="m", - tenant_id="tenant-1", - ) - == "mcp-tool" + result = ToolManager.get_tool_runtime( + provider_type=ToolProviderType.MCP, + provider_id="mcp-1", + tool_name="m", + tenant_id="tenant-1", + user_id="caller-1", + invoke_from=InvokeFrom.SERVICE_API, + tool_invoke_from=ToolInvokeFrom.WORKFLOW, + tool_providers=tool_providers, + workflow_queries=workflow_queries, ) + assert result is tool + assert tool.runtime.tenant_id == "tenant-1" + assert tool.runtime.user_id == "caller-1" + assert tool.runtime.invoke_from == InvokeFrom.SERVICE_API + assert tool.runtime.tool_invoke_from == ToolInvokeFrom.WORKFLOW + -def test_get_tool_runtime_app_not_implemented(): +def test_get_tool_runtime_app_not_implemented(*, tool_providers, workflow_queries): with pytest.raises(NotImplementedError, match="app provider not implemented"): ToolManager.get_tool_runtime( provider_type=ToolProviderType.APP, provider_id="app", tool_name="x", tenant_id="tenant-1", + tool_providers=tool_providers, + workflow_queries=workflow_queries, ) -def test_get_agent_runtime_apply_runtime_parameters(): +def test_get_agent_runtime_apply_runtime_parameters(*, tool_providers, workflow_queries, workflow_runtime): + saver = Mock() parameter = ToolParameter.get_simple_instance( name="query", llm_description="query", @@ -757,7 +740,7 @@ def test_get_agent_runtime_apply_runtime_parameters(): with patch.object(ToolManager, "_convert_tool_parameters_type", return_value={"query": "hello"}): manager = Mock() manager.decrypt_tool_parameters.return_value = {"query": "decrypted"} - with patch("core.tools.tool_manager.ToolParameterConfigurationManager", return_value=manager): + with patch("services.tools.tool_manager.ToolParameterConfigurationManager", return_value=manager): agent_tool = SimpleNamespace( provider_type=ToolProviderType.API, provider_id="api-1", @@ -766,17 +749,23 @@ def test_get_agent_runtime_apply_runtime_parameters(): credential_id=None, ) result = ToolManager.get_agent_tool_runtime( + draft_variable_saver=saver, + workflow_runtime=workflow_runtime, tenant_id="tenant-1", app_id="app-1", agent_tool=agent_tool, user_id="user-1", invoke_from=InvokeFrom.DEBUGGER, variable_pool=None, + tool_providers=tool_providers, + workflow_queries=workflow_queries, ) assert result is tool_runtime assert tool_runtime.runtime.runtime_parameters["query"] == "decrypted" mock_get_tool_runtime.assert_called_once_with( + tool_providers=tool_providers, + workflow_queries=workflow_queries, provider_type=ToolProviderType.API, provider_id="api-1", tool_name="search", @@ -785,11 +774,13 @@ def test_get_agent_runtime_apply_runtime_parameters(): invoke_from=InvokeFrom.DEBUGGER, tool_invoke_from=ToolInvokeFrom.AGENT, credential_id=None, - execution_driver=None, + draft_variable_saver=saver, + workflow_runtime=workflow_runtime, ) -def test_get_workflow_runtime_apply_runtime_parameters(): +def test_get_workflow_runtime_apply_runtime_parameters(*, tool_providers, workflow_queries, workflow_runtime): + saver = Mock() parameter = ToolParameter.get_simple_instance( name="query", llm_description="query", @@ -811,8 +802,10 @@ def test_get_workflow_runtime_apply_runtime_parameters(): with patch.object(ToolManager, "_convert_tool_parameters_type", return_value={"query": "workflow"}): manager = Mock() manager.decrypt_tool_parameters.return_value = {"query": "workflow-dec"} - with patch("core.tools.tool_manager.ToolParameterConfigurationManager", return_value=manager): + with patch("services.tools.tool_manager.ToolParameterConfigurationManager", return_value=manager): workflow_result = ToolManager.get_workflow_tool_runtime( + draft_variable_saver=saver, + workflow_runtime=workflow_runtime, tenant_id="tenant-1", app_id="app-1", node_id="node-1", @@ -820,11 +813,15 @@ def test_get_workflow_runtime_apply_runtime_parameters(): user_id="user-1", invoke_from=InvokeFrom.DEBUGGER, variable_pool=None, + tool_providers=tool_providers, + workflow_queries=workflow_queries, ) assert workflow_result is tool_runtime2 assert tool_runtime2.runtime.runtime_parameters["query"] == "workflow-dec" mock_get_tool_runtime.assert_called_once_with( + tool_providers=tool_providers, + workflow_queries=workflow_queries, provider_type=ToolProviderType.API, provider_id="api-1", tool_name="search", @@ -833,11 +830,12 @@ def test_get_workflow_runtime_apply_runtime_parameters(): invoke_from=InvokeFrom.DEBUGGER, tool_invoke_from=ToolInvokeFrom.WORKFLOW, credential_id=None, - execution_driver=None, + draft_variable_saver=saver, + workflow_runtime=workflow_runtime, ) -def test_get_agent_runtime_raises_when_runtime_missing(): +def test_get_agent_runtime_raises_when_runtime_missing(*, tool_providers, workflow_queries): tool_runtime = SimpleNamespace(runtime=None, get_merged_runtime_parameters=lambda: []) agent_tool = SimpleNamespace( provider_type=ToolProviderType.API, @@ -848,16 +846,18 @@ def test_get_agent_runtime_raises_when_runtime_missing(): ) with patch.object(ToolManager, "get_tool_runtime", return_value=tool_runtime): with patch.object(ToolManager, "_convert_tool_parameters_type", return_value={}): - with patch("core.tools.tool_manager.ToolParameterConfigurationManager", return_value=Mock()): + with patch("services.tools.tool_manager.ToolParameterConfigurationManager", return_value=Mock()): with pytest.raises(ValueError, match="runtime not found"): ToolManager.get_agent_tool_runtime( tenant_id="tenant-1", app_id="app-1", agent_tool=agent_tool, + tool_providers=tool_providers, + workflow_queries=workflow_queries, ) -def test_get_tool_runtime_from_plugin_only_uses_form_parameters(): +def test_get_tool_runtime_from_plugin_only_uses_form_parameters(workflow_runtime, workflow_variables): form_param = ToolParameter.get_simple_instance( name="q", llm_description="query", @@ -878,6 +878,8 @@ def test_get_tool_runtime_from_plugin_only_uses_form_parameters(): with patch.object(ToolManager, "get_tool_runtime", return_value=tool_entity) as mock_get_tool_runtime: result = ToolManager.get_tool_runtime_from_plugin( + workflow_runtime=workflow_runtime, + draft_variable_saver=workflow_variables.saver_factory, tool_type=ToolProviderType.API, tenant_id="tenant-1", provider="api-1", @@ -889,6 +891,8 @@ def test_get_tool_runtime_from_plugin_only_uses_form_parameters(): assert result is tool_entity assert tool_entity.runtime.runtime_parameters == {"q": "hello"} mock_get_tool_runtime.assert_called_once_with( + tool_providers=workflow_runtime.tool_providers, + workflow_queries=workflow_runtime.tools, provider_type=ToolProviderType.API, provider_id="api-1", tool_name="search", @@ -897,7 +901,8 @@ def test_get_tool_runtime_from_plugin_only_uses_form_parameters(): invoke_from=InvokeFrom.SERVICE_API, tool_invoke_from=ToolInvokeFrom.PLUGIN, credential_id=None, - execution_driver=None, + workflow_runtime=workflow_runtime, + draft_variable_saver=workflow_variables.saver_factory, ) @@ -905,8 +910,8 @@ def test_hardcoded_provider_icon_success(): provider = SimpleNamespace(entity=SimpleNamespace(identity=SimpleNamespace(icon="icon.svg"))) provider.provider_directory = "/providers/time" with patch.object(ToolManager, "get_hardcoded_provider", return_value=provider): - with patch("core.tools.tool_manager.path.exists", return_value=True): - with patch("core.tools.tool_manager.mimetypes.guess_type", return_value=("image/svg+xml", None)): + with patch("services.tools.tool_manager.path.exists", return_value=True): + with patch("services.tools.tool_manager.mimetypes.guess_type", return_value=("image/svg+xml", None)): icon_path, mime = ToolManager.get_hardcoded_provider_icon("time") assert icon_path.endswith("icon.svg") assert mime == "image/svg+xml" @@ -916,7 +921,7 @@ def test_hardcoded_provider_icon_missing_raises(): provider = SimpleNamespace(entity=SimpleNamespace(identity=SimpleNamespace(icon="icon.svg"))) provider.provider_directory = "/providers/time" with patch.object(ToolManager, "get_hardcoded_provider", return_value=provider): - with patch("core.tools.tool_manager.path.exists", return_value=False): + with patch("services.tools.tool_manager.path.exists", return_value=False): with pytest.raises(ToolProviderNotFoundError, match="icon not found"): ToolManager.get_hardcoded_provider_icon("time") @@ -942,10 +947,10 @@ def test_list_hardcoded_providers_internal_loader(): ) provider_class = Mock(return_value=good_provider) - with patch("core.tools.tool_manager.listdir", return_value=["good", "bad", "__skip"]): - with patch("core.tools.tool_manager.path.isdir", side_effect=lambda p: "good" in p or "bad" in p): + with patch("services.tools.tool_manager.listdir", return_value=["good", "bad", "__skip"]): + with patch("services.tools.tool_manager.path.isdir", side_effect=lambda p: "good" in p or "bad" in p): with patch( - "core.tools.tool_manager.load_single_subclass_from_source", + "services.tools.tool_manager.load_single_subclass_from_source", side_effect=[provider_class, RuntimeError("boom")], ): ToolManager._hardcoded_providers = {} @@ -992,7 +997,7 @@ def test_registered_external_provider_classes_join_hardcoded_providers(): ToolManager.register_hardcoded_provider_class(_ExternalProvider) assert ToolManager._builtin_providers_loaded is False - with patch("core.tools.tool_manager.listdir", return_value=[]): + with patch("services.tools.tool_manager.listdir", return_value=[]): ToolManager._hardcoded_providers = {} ToolManager._builtin_tools_labels = {} providers = list(ToolManager._list_hardcoded_providers()) @@ -1014,164 +1019,28 @@ def _load(): assert ToolManager.get_tool_label("missing") is None -@pytest.mark.parametrize("database_scheme", ["mysql", "postgresql"]) -def test_list_default_builtin_providers_uses_persisted_defaults( - monkeypatch: pytest.MonkeyPatch, - tool_database: _ToolDatabase, - database_scheme: str, -): - tenant_id = "00000000-0000-0000-0000-000000000001" - default_provider = _builtin_provider( - provider_id="00000000-0000-0000-0000-000000000002", - tenant_id=tenant_id, - name="default", - is_default=True, - ) - older_provider = _builtin_provider( - provider_id="00000000-0000-0000-0000-000000000003", - tenant_id=tenant_id, - name="older", - is_default=False, - ) - other_tenant_provider = _builtin_provider( - provider_id="00000000-0000-0000-0000-000000000004", - tenant_id="00000000-0000-0000-0000-000000000005", - name="foreign", - ) - default_provider.created_at = datetime(2026, 1, 2) - older_provider.created_at = datetime(2026, 1, 1) - tool_database.session.add_all([default_provider, older_provider, other_tenant_provider]) - tool_database.session.commit() - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) - monkeypatch.setattr( - "core.tools.tool_manager.dify_config", - SimpleNamespace(SQLALCHEMY_DATABASE_URI_SCHEME=database_scheme), - ) - - postgresql_statements = [] - - def translate_postgresql_distinct_on(_connection, _cursor, statement, parameters, _context, _executemany): - if "SELECT DISTINCT ON (tenant_id, provider) id" not in statement: - return statement, parameters - - postgresql_statements.append(statement) - sqlite_statement = """ - SELECT id FROM ( - SELECT id, - ROW_NUMBER() OVER ( - PARTITION BY tenant_id, provider - ORDER BY is_default DESC, created_at DESC - ) AS rn - FROM tool_builtin_providers - WHERE tenant_id = ? - ) ranked WHERE rn = 1 - """ - return sqlite_statement, parameters - - if database_scheme == "postgresql": - event.listen( - tool_database.engine, - "before_cursor_execute", - translate_postgresql_distinct_on, - retval=True, - ) - - try: - providers = ToolManager.list_default_builtin_providers(tenant_id) - finally: - if database_scheme == "postgresql": - event.remove( - tool_database.engine, - "before_cursor_execute", - translate_postgresql_distinct_on, - ) - - assert [provider.id for provider in providers] == [default_provider.id] - if database_scheme == "postgresql": - assert postgresql_statements - - -def test_list_providers_from_api_covers_builtin_api_workflow_and_mcp( - monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase -): - tenant_id = "00000000-0000-0000-0000-000000000001" - hardcoded_controller = SimpleNamespace(entity=SimpleNamespace(identity=SimpleNamespace(name="hardcoded"))) - plugin_controller = object.__new__(PluginToolProviderController) - plugin_controller.entity = SimpleNamespace(identity=SimpleNamespace(name="plugin-provider")) - - api_db_provider_good = _api_provider( - provider_id="00000000-0000-0000-0000-000000000002", tenant_id=tenant_id, name="api-good" - ) - api_db_provider_bad = _api_provider( - provider_id="00000000-0000-0000-0000-000000000003", tenant_id=tenant_id, name="api-bad" - ) - api_controller = SimpleNamespace(provider_id=api_db_provider_good.id) - - workflow_db_provider_good = _workflow_provider( - provider_id="00000000-0000-0000-0000-000000000004", tenant_id=tenant_id, name="workflow-good" - ) - workflow_db_provider_bad = _workflow_provider( - provider_id="00000000-0000-0000-0000-000000000005", tenant_id=tenant_id, name="workflow-bad" - ) - workflow_controller = SimpleNamespace(provider_id=workflow_db_provider_good.id) - tool_database.session.add_all( - [ - api_db_provider_good, - api_db_provider_bad, - _api_provider( - provider_id="00000000-0000-0000-0000-000000000006", - tenant_id="00000000-0000-0000-0000-000000000099", - name="foreign-api", - ), - workflow_db_provider_good, - workflow_db_provider_bad, - _workflow_provider( - provider_id="00000000-0000-0000-0000-000000000007", - tenant_id="00000000-0000-0000-0000-000000000099", - name="foreign-workflow", - ), - ] - ) - tool_database.session.commit() - - _setup_list_providers_from_api_mocks( - monkeypatch, - tool_database=tool_database, - hardcoded_controller=hardcoded_controller, - plugin_controller=plugin_controller, - api_controller=api_controller, - workflow_controller=workflow_controller, - ) - providers = ToolManager.list_providers_from_api(user_id="user-1", tenant_id=tenant_id, typ="") - - names = {provider.name for provider in providers} - assert {"hardcoded", "plugin-provider", "api-provider", "workflow-provider", "mcp-provider"} <= names - - -def test_get_api_provider_controller_returns_controller_and_credentials( - monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase -): +def test_get_api_provider_controller_returns_controller_and_credentials(tool_database: _ToolDatabase, tool_providers): tenant_id = "00000000-0000-0000-0000-000000000001" provider = _api_provider(provider_id="00000000-0000-0000-0000-000000000002", tenant_id=tenant_id) tool_database.session.add(provider) tool_database.session.commit() - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) - built_controller, credentials = ToolManager.get_api_provider_controller(tenant_id, provider.id) - - assert built_controller.provider_id == provider.id - assert built_controller.tenant_id == tenant_id - assert built_controller.tools == [] + controller, credentials = ToolManager.get_api_provider_controller( + tenant_id, provider.id, tool_providers=tool_providers + ) + assert controller.provider_id == provider.id + assert controller.entity.identity.name == provider.name + assert controller.entity.identity.author == "" assert credentials == provider.credentials + assert [tool.entity.identity.name for tool in controller.get_tools(tenant_id)] == [ + tool.operation_id for tool in provider.tools + ] -def test_user_get_api_provider_masks_credentials_and_adds_labels( - monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase -): +def test_user_get_api_provider_masks_credentials_and_adds_labels(tool_database: _ToolDatabase, tool_providers): tenant_id = "00000000-0000-0000-0000-000000000001" provider = _api_provider(provider_id="00000000-0000-0000-0000-000000000002", tenant_id=tenant_id) tool_database.session.add(provider) tool_database.session.commit() - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) cache = NoOpProviderCredentialCache() encrypter = ProviderConfigEncrypter( tenant_id=tenant_id, @@ -1179,16 +1048,16 @@ def test_user_get_api_provider_masks_credentials_and_adds_labels( provider_config_cache=cache, ) with ( - patch("core.tools.tool_manager.create_tool_provider_encrypter", return_value=(encrypter, cache)), - patch("core.tools.tool_manager.ToolLabelManager.get_tool_labels", return_value=["search"]), + patch("services.tools.tool_manager.create_tool_provider_encrypter", return_value=(encrypter, cache)), + patch.object(tool_providers, "api_labels", return_value={provider.id: ["search"]}), ): - user_payload = ToolManager.user_get_api_provider(provider.name, tenant_id) + user_payload = ToolManager.user_get_api_provider(provider.name, tenant_id, tool_providers=tool_providers) assert user_payload["credentials"]["api_key_value"] == "******" assert user_payload["labels"] == ["search"] -def test_get_api_provider_controller_not_found_raises(monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase): +def test_get_api_provider_controller_not_found_raises(tool_database: _ToolDatabase, tool_providers): provider_id = "00000000-0000-0000-0000-000000000002" tool_database.session.add( _api_provider( @@ -1197,43 +1066,11 @@ def test_get_api_provider_controller_not_found_raises(monkeypatch: pytest.Monkey ) ) tool_database.session.commit() - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) with pytest.raises(ToolProviderNotFoundError, match=f"api provider {provider_id} not found"): - ToolManager.get_api_provider_controller("00000000-0000-0000-0000-000000000001", provider_id) - - -def test_get_mcp_provider_controller_returns_controller(monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase): - provider_entity = SimpleNamespace(provider_icon={"background": "#111", "content": "M"}) - controller = Mock() - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) - with patch("core.tools.tool_manager.MCPToolManageService") as mock_service_cls: - mock_service = mock_service_cls.return_value - mock_service.get_provider_by_persisted_reference.return_value = provider_entity - with patch("core.tools.tool_manager.MCPToolProviderController.from_db", return_value=controller): - built = ToolManager.get_mcp_provider_controller("tenant-1", "mcp-1") - assert built is controller - assert isinstance(mock_service_cls.call_args.kwargs["session"], Session) - - -def test_generate_mcp_tool_icon_url_returns_provider_icon( - monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase -): - provider_entity = SimpleNamespace(provider_icon={"background": "#111", "content": "M"}) - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) - with patch("core.tools.tool_manager.MCPToolManageService") as mock_service_cls: - mock_service = mock_service_cls.return_value - mock_service.get_provider_entity_by_persisted_reference.return_value = provider_entity - assert ToolManager.generate_mcp_tool_icon_url("tenant-1", "mcp-1") == provider_entity.provider_icon - assert isinstance(mock_service_cls.call_args.kwargs["session"], Session) - - -def test_get_mcp_provider_controller_missing_raises(monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase): - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) - with patch("core.tools.tool_manager.MCPToolManageService") as mock_service_cls: - mock_service_cls.return_value.get_provider_by_persisted_reference.side_effect = ValueError("missing") - with pytest.raises(ToolProviderNotFoundError, match="mcp provider mcp-1 not found"): - ToolManager.get_mcp_provider_controller("tenant-1", "mcp-1") + ToolManager.get_api_provider_controller( + "00000000-0000-0000-0000-000000000001", provider_id, tool_providers=tool_providers + ) def test_generate_tool_icon_urls_for_builtin_and_plugin(config_overrides: Callable[..., None]): @@ -1245,88 +1082,42 @@ def test_generate_tool_icon_urls_for_builtin_and_plugin(config_overrides: Callab assert "/plugin/icon" in plugin_url -def test_generate_tool_icon_urls_for_workflow_and_api(monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase): - tenant_id = "00000000-0000-0000-0000-000000000001" - workflow_provider = _workflow_provider( - provider_id="00000000-0000-0000-0000-000000000002", - tenant_id=tenant_id, - ) - api_provider = _api_provider( - provider_id="00000000-0000-0000-0000-000000000003", - tenant_id=tenant_id, - icon='{"background":"#333","content":"A"}', - ) - tool_database.session.add_all([workflow_provider, api_provider]) - tool_database.session.commit() - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) - - assert ToolManager.generate_workflow_tool_icon_url(tenant_id, workflow_provider.id) == { - "background": "#222", - "content": "W", - } - assert ToolManager.generate_api_tool_icon_url(tenant_id, api_provider.id) == { - "background": "#333", - "content": "A", - } - - -def test_generate_tool_icon_urls_missing_workflow_and_api_use_default( - monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase -): - tenant_id = "00000000-0000-0000-0000-000000000001" - foreign_tenant_id = "00000000-0000-0000-0000-000000000099" - workflow_provider_id = "00000000-0000-0000-0000-000000000002" - api_provider_id = "00000000-0000-0000-0000-000000000003" - tool_database.session.add_all( - [ - _workflow_provider(provider_id=workflow_provider_id, tenant_id=foreign_tenant_id), - _api_provider(provider_id=api_provider_id, tenant_id=foreign_tenant_id), - ] - ) - tool_database.session.commit() - monkeypatch.setattr("core.tools.tool_manager.db", tool_database) - - assert ToolManager.generate_workflow_tool_icon_url(tenant_id, workflow_provider_id)["background"] == "#252525" - assert ToolManager.generate_api_tool_icon_url(tenant_id, api_provider_id)["background"] == "#252525" - - -def test_get_tool_icon_for_builtin_provider_variants(): +def test_get_tool_icon_for_builtin_provider_variants(*, tool_providers): plugin_provider = object.__new__(PluginToolProviderController) plugin_provider.entity = SimpleNamespace(identity=SimpleNamespace(icon="plugin.svg")) with patch.object(ToolManager, "get_builtin_provider", return_value=plugin_provider): with patch.object(ToolManager, "generate_plugin_tool_icon_url", return_value="plugin-icon"): - assert ToolManager.get_tool_icon("tenant-1", ToolProviderType.BUILT_IN, "plugin-provider") == "plugin-icon" + assert ( + ToolManager.get_tool_icon( + "tenant-1", ToolProviderType.BUILT_IN, "plugin-provider", tool_providers=tool_providers + ) + == "plugin-icon" + ) with patch.object(ToolManager, "get_builtin_provider", return_value=SimpleNamespace()): with patch.object(ToolManager, "generate_builtin_tool_icon_url", return_value="builtin-icon"): - assert ToolManager.get_tool_icon("tenant-1", ToolProviderType.BUILT_IN, "time") == "builtin-icon" - - -def test_get_tool_icon_for_api_workflow_and_mcp(): - with patch.object(ToolManager, "generate_api_tool_icon_url", return_value={"background": "#000"}): - assert ToolManager.get_tool_icon("tenant-1", ToolProviderType.API, "api-1") == {"background": "#000"} - - with patch.object(ToolManager, "generate_workflow_tool_icon_url", return_value={"background": "#111"}): - assert ToolManager.get_tool_icon("tenant-1", ToolProviderType.WORKFLOW, "wf-1") == {"background": "#111"} - - with patch.object(ToolManager, "generate_mcp_tool_icon_url", return_value={"background": "#222"}): - assert ToolManager.get_tool_icon("tenant-1", ToolProviderType.MCP, "mcp-1") == {"background": "#222"} + assert ( + ToolManager.get_tool_icon("tenant-1", ToolProviderType.BUILT_IN, "time", tool_providers=tool_providers) + == "builtin-icon" + ) -def test_get_tool_icon_plugin_error_returns_default(): +def test_get_tool_icon_plugin_error_returns_default(*, tool_providers): plugin_provider = object.__new__(PluginToolProviderController) plugin_provider.entity = SimpleNamespace(identity=SimpleNamespace(icon="plugin.svg")) with patch.object(ToolManager, "get_plugin_provider", return_value=plugin_provider): with patch.object(ToolManager, "generate_plugin_tool_icon_url", side_effect=RuntimeError("fail")): - icon = ToolManager.get_tool_icon("tenant-1", ToolProviderType.PLUGIN, "plugin-provider") + icon = ToolManager.get_tool_icon( + "tenant-1", ToolProviderType.PLUGIN, "plugin-provider", tool_providers=tool_providers + ) assert icon["background"] == "#252525" -def test_get_tool_icon_invalid_provider_type_raises(): +def test_get_tool_icon_invalid_provider_type_raises(*, tool_providers): with pytest.raises(ValueError, match="provider type"): - ToolManager.get_tool_icon("tenant-1", "invalid", "x") # type: ignore[arg-type] + ToolManager.get_tool_icon("tenant-1", "invalid", "x", tool_providers=tool_providers) # type: ignore[arg-type] def test_convert_tool_parameters_type_agent_and_workflow_branches(): @@ -1364,7 +1155,7 @@ def test_convert_tool_parameters_type_agent_and_workflow_branches(): variable_pool = VariablePool() variable_pool.add(["sys", "query"], "from-variable") - with patch("core.tools.tool_manager.convert_template", return_value=SimpleNamespace(text="from-template")): + with patch("services.tools.tool_manager.convert_template", return_value=SimpleNamespace(text="from-template")): mixed = ToolManager._convert_tool_parameters_type( parameters=[text_param], variable_pool=variable_pool, diff --git a/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py b/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py index 30513ad0deb933..20d771a87a94d5 100644 --- a/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py +++ b/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py @@ -5,13 +5,13 @@ from collections.abc import Iterator from types import SimpleNamespace from typing import Any, cast -from unittest.mock import PropertyMock, patch +from unittest.mock import Mock, create_autospec, patch import pytest from sqlalchemy import Engine from sqlalchemy.orm import Session, sessionmaker -from core.db.session_factory import session_factory +from core.app.apps.draft_variable_saver import DraftVariableSaverFactory from core.tools.__base.tool_runtime import ToolRuntime from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_entities import ( @@ -23,15 +23,16 @@ ToolProviderIdentity, ToolProviderType, ) -from core.tools.workflow_as_tool.provider import WorkflowToolProviderController -from core.tools.workflow_as_tool.tool import WorkflowTool -from extensions.ext_database import db from graphon.variables.input_entities import VariableEntity, VariableEntityType -from models.account import Account +from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.base import TypeBase from models.model import App, AppMode +from models.tool_runtime_contracts import WorkflowToolQueries from models.tools import WorkflowToolProvider from models.workflow import Workflow +from services.tools.workflow.provider import WorkflowToolProviderController +from services.tools.workflow.tool import WorkflowTool +from services.workflow.execution.ports import WorkflowRuntime from tests.helpers.unit.model_factories import make_account, make_app, make_workflow @@ -42,15 +43,13 @@ def database_session(sqlite_engine: Engine) -> Iterator[Session]: TypeBase.metadata.create_all(sqlite_engine, tables=tables) session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) - with ( - patch.object(session_factory, "create_session", session_maker), - patch.object(type(db), "engine", new_callable=PropertyMock, return_value=sqlite_engine), - ): - with session_maker() as session: - yield session + with session_maker() as session: + yield session -def _controller(provider_id: str = "provider-1") -> WorkflowToolProviderController: +def _controller( + provider_id: str = "provider-1", *, workflow_queries: WorkflowToolQueries +) -> WorkflowToolProviderController: entity = ToolProviderEntity( identity=ToolProviderIdentity( author="author", @@ -61,7 +60,14 @@ def _controller(provider_id: str = "provider-1") -> WorkflowToolProviderControll ), credentials_schema=[], ) - return WorkflowToolProviderController(entity=entity, provider_id=provider_id) + return WorkflowToolProviderController( + draft_variable_saver=Mock( + return_value=create_autospec(DraftVariableSaverFactory, instance=True, spec_set=True) + ), + entity=entity, + provider_id=provider_id, + queries=workflow_queries, + ) def _app(*, tenant_id: str | None = None) -> App: @@ -108,10 +114,15 @@ def _db_provider( ) -def _workflow_tool(name: str = "workflow_tool", *, tenant_id: str | None = None) -> WorkflowTool: +def _workflow_tool( + name: str = "workflow_tool", *, tenant_id: str | None = None, workflow_runtime: WorkflowRuntime +) -> WorkflowTool: app = _app(tenant_id=tenant_id) workflow = _workflow(app) return WorkflowTool( + draft_variable_saver=Mock( + return_value=create_autospec(DraftVariableSaverFactory, instance=True, spec_set=True) + ), workflow_as_tool_id="provider-1", entity=ToolEntity( identity=ToolIdentity( @@ -128,6 +139,8 @@ def _workflow_tool(name: str = "workflow_tool", *, tenant_id: str | None = None) workflow_id=workflow.id, version="1", workflow_call_depth=0, + workflow_runtime=workflow_runtime, + queries=workflow_runtime.tools, ) @@ -143,7 +156,16 @@ def _persist_provider_graph( workflow = _workflow(app, account) db_provider = _db_provider(app, account, parameter_configuration=parameter_configuration) - session.add_all([account, db_provider]) + tenant = Tenant(name="Tenant") + tenant.id = app.tenant_id + session.add_all( + [ + account, + db_provider, + tenant, + TenantAccountJoin(account_id=account.id, tenant_id=app.tenant_id, role=TenantAccountRole.OWNER), + ] + ) if include_app: session.add(app) if include_workflow: @@ -152,8 +174,8 @@ def _persist_provider_graph( return db_provider, app, account, workflow -def test_get_db_provider_tool_builds_entity(database_session: Session): - db_provider, app, user, _ = _persist_provider_graph( +def test_get_tools_builds_entity(database_session: Session, *, workflow_queries: WorkflowToolQueries) -> None: + db_provider, _, _, _ = _persist_provider_graph( database_session, parameter_configuration=json.dumps( [ @@ -162,7 +184,7 @@ def test_get_db_provider_tool_builds_entity(database_session: Session): ] ), ) - controller = _controller(db_provider.id) + controller = _controller(db_provider.id, workflow_queries=workflow_queries) variables = [ VariableEntity( variable="country", @@ -180,19 +202,19 @@ def test_get_db_provider_tool_builds_entity(database_session: Session): with ( patch( - "core.tools.workflow_as_tool.provider.WorkflowAppConfigManager.convert_features", + "services.tools.workflow.provider.BaseAppConfigManager.convert_features", return_value=SimpleNamespace(file_upload=True), ), patch( - "core.tools.workflow_as_tool.provider.WorkflowToolConfigurationUtils.get_workflow_graph_variables", + "services.tools.workflow.provider.WorkflowToolConfigurationUtils.get_workflow_graph_variables", return_value=variables, ), patch( - "core.tools.workflow_as_tool.provider.WorkflowToolConfigurationUtils.get_workflow_graph_output", + "services.tools.workflow.provider.WorkflowToolConfigurationUtils.get_workflow_graph_output", return_value=outputs, ), ): - tool = controller._get_db_provider_tool(db_provider, session=database_session, user=user) + tool = controller.get_tools(db_provider.tenant_id)[0] assert tool.entity.identity.name == "workflow_tool" # "json" output is reserved for ToolInvokeMessage.VariableMessage and filtered out. @@ -204,41 +226,49 @@ def test_get_db_provider_tool_builds_entity(database_session: Session): assert controller.provider_type == ToolProviderType.WORKFLOW -def test_get_tool_returns_hit_or_none(): - controller = _controller() - tool = _workflow_tool() +def test_get_tool_returns_hit_or_none( + *, workflow_runtime: WorkflowRuntime, workflow_queries: WorkflowToolQueries +) -> None: + controller = _controller(workflow_queries=workflow_queries) + tool = _workflow_tool(workflow_runtime=workflow_runtime) controller.tools = [tool] assert controller.get_tool("workflow_tool") is tool assert controller.get_tool("missing") is None -def test_get_tools_returns_cached(): - controller = _controller() - cached_tools = [_workflow_tool("wf-cached", tenant_id="tenant-1")] +def test_get_tools_returns_cached(*, workflow_runtime: WorkflowRuntime, workflow_queries: WorkflowToolQueries) -> None: + controller = _controller(workflow_queries=workflow_queries) + cached_tools = [_workflow_tool("wf-cached", tenant_id="tenant-1", workflow_runtime=workflow_runtime)] controller.tools = cached_tools assert controller.get_tools("tenant-1") == cached_tools -def test_from_db_builds_controller(database_session: Session): +def test_from_db_builds_controller(database_session: Session, *, workflow_queries: WorkflowToolQueries) -> None: db_provider, app, user, workflow = _persist_provider_graph(database_session) with ( patch( - "core.tools.workflow_as_tool.provider.WorkflowAppConfigManager.convert_features", + "services.tools.workflow.provider.BaseAppConfigManager.convert_features", return_value=SimpleNamespace(file_upload=False), ), patch( - "core.tools.workflow_as_tool.provider.WorkflowToolConfigurationUtils.get_workflow_graph_variables", + "services.tools.workflow.provider.WorkflowToolConfigurationUtils.get_workflow_graph_variables", return_value=[], ), patch( - "core.tools.workflow_as_tool.provider.WorkflowToolConfigurationUtils.get_workflow_graph_output", + "services.tools.workflow.provider.WorkflowToolConfigurationUtils.get_workflow_graph_output", return_value=[], ), ): - built = WorkflowToolProviderController.from_db(db_provider) + built = WorkflowToolProviderController.from_db( + db_provider, + draft_variable_saver=Mock( + return_value=create_autospec(DraftVariableSaverFactory, instance=True, spec_set=True) + ), + queries=workflow_queries, + ) assert isinstance(built, WorkflowToolProviderController) assert built.entity.identity.author == user.name @@ -248,21 +278,23 @@ def test_from_db_builds_controller(database_session: Session): assert built.tools[0].workflow_id == workflow.id -def test_get_tools_returns_empty_when_provider_missing(database_session: Session): +def test_get_tools_returns_empty_when_provider_missing( + database_session: Session, *, workflow_queries: WorkflowToolQueries +) -> None: db_provider, _, _, _ = _persist_provider_graph(database_session) - controller = _controller(db_provider.id) + controller = _controller(db_provider.id, workflow_queries=workflow_queries) controller.tools = None assert controller.get_tools(str(uuid.uuid4())) == [] -def test_get_tools_raises_when_app_missing(database_session: Session): +def test_get_tools_raises_when_app_missing(database_session: Session, *, workflow_queries: WorkflowToolQueries) -> None: db_provider, _, _, _ = _persist_provider_graph( database_session, include_app=False, include_workflow=False, ) - controller = _controller(db_provider.id) + controller = _controller(db_provider.id, workflow_queries=workflow_queries) controller.tools = None with pytest.raises(ValueError, match="app not found"): diff --git a/api/tests/unit_tests/services/tools/test_api_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_api_tools_manage_service.py index e0db0bec4ae8ff..db1933927fbdd9 100644 --- a/api/tests/unit_tests/services/tools/test_api_tools_manage_service.py +++ b/api/tests/unit_tests/services/tools/test_api_tools_manage_service.py @@ -2,7 +2,7 @@ import pytest -from services.tools.legacy_api_tools_manage_service import ApiToolManageService +from services.tools.api_tools_manage_service import ApiToolManageService def test_get_api_tool_provider_remote_schema_uses_ssrf_proxy_get( @@ -25,7 +25,7 @@ def test_get_api_tool_provider_remote_schema_uses_ssrf_proxy_get( mock_get = Mock(return_value=Mock(status_code=200, text=schema)) mock_parser = Mock() - monkeypatch.setattr("services.tools.legacy_api_tools_manage_service.ssrf_proxy.get", mock_get) + monkeypatch.setattr("services.tools.api_tools_manage_service.ssrf_proxy.get", mock_get) monkeypatch.setattr(ApiToolManageService, "parser_api_schema", mock_parser) result = ApiToolManageService.get_api_tool_provider_remote_schema("user-1", "tenant-1", url) diff --git a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py index 59827ba2671c81..d79a887269a323 100644 --- a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py +++ b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py @@ -21,8 +21,10 @@ from core.tools.plugin_tool.provider import PluginToolProviderController from models.tools import BuiltinToolProvider, ToolOAuthSystemClient, ToolOAuthTenantClient from repositories.credentials.query_repository import CredentialQueryRepository -from services.tools import legacy_builtin_tools_manage_service as service_module -from services.tools.legacy_builtin_tools_manage_service import BuiltinToolManageService +from repositories.tools.provider_repository import ToolProviderRepository +from services.tools import builtin_tools_manage_service as service_module +from services.tools.builtin import credentials as credential_module +from services.tools.builtin_tools_manage_service import BuiltinToolManageService @pytest.fixture @@ -107,12 +109,13 @@ def test_transforms_each_tool(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(service_module.ToolManager, "get_builtin_provider", MagicMock(return_value=controller)) convert = MagicMock(return_value=MagicMock()) monkeypatch.setattr(service_module.ToolTransformService, "convert_tool_entity_to_api_entity", convert) - monkeypatch.setattr(service_module.ToolLabelManager, "get_tool_labels", MagicMock(return_value=[])) + controller.tool_labels = ["search"] result = BuiltinToolManageService.list_builtin_tool_provider_tools("tenant-1", "google") assert len(result) == 2 assert convert.call_count == 2 + assert all(call.kwargs["labels"] == ["search"] for call in convert.call_args_list) def test_empty_tools(self, monkeypatch: pytest.MonkeyPatch) -> None: controller = MagicMock() @@ -516,19 +519,20 @@ def test_system_client_binds_verified_package_and_reads_rotated_credentials( ) monkeypatch.setattr(service_module.ToolManager, "get_plugin_provider", lambda *_args: controller) monkeypatch.setattr( - service_module.PluginService, "is_plugin_verified", lambda _tenant, value: value == identifier + credential_module.PluginService, "is_plugin_verified", lambda _tenant, value: value == identifier ) - monkeypatch.setattr(service_module, "decrypt_system_params", json.loads) + monkeypatch.setattr(credential_module, "decrypt_system_params", json.loads) + tool_providers = ToolProviderRepository(sessionmaker(repository_session.get_bind(), expire_on_commit=False)) row = _persist_system_oauth_client(repository_session) row.encrypted_oauth_params = json.dumps({"client_secret": "first-test-secret"}) repository_session.commit() - first = BuiltinToolManageService.get_oauth_client("tenant-1", "google") + first = BuiltinToolManageService.get_oauth_client("tenant-1", "google", tool_providers=tool_providers) assert first is not None assert first.plugin_unique_identifier == identifier row.encrypted_oauth_params = json.dumps({"client_secret": "rotated-test-secret"}) repository_session.commit() - second = BuiltinToolManageService.get_oauth_client("tenant-1", "google") + second = BuiltinToolManageService.get_oauth_client("tenant-1", "google", tool_providers=tool_providers) assert second is not None assert second.credentials == {"client_secret": "rotated-test-secret"} @@ -538,12 +542,12 @@ def replace_during_verification(_tenant: str, value: str) -> bool: controller.plugin_unique_identifier = replacement_identifier return value == identifier - monkeypatch.setattr(service_module.PluginService, "is_plugin_verified", replace_during_verification) - third = BuiltinToolManageService.get_oauth_client("tenant-1", "google") + monkeypatch.setattr(credential_module.PluginService, "is_plugin_verified", replace_during_verification) + third = BuiltinToolManageService.get_oauth_client("tenant-1", "google", tool_providers=tool_providers) assert third is not None assert third.plugin_unique_identifier == identifier assert controller.plugin_unique_identifier == replacement_identifier - assert BuiltinToolManageService.get_oauth_client("tenant-1", "google") is None + assert BuiltinToolManageService.get_oauth_client("tenant-1", "google", tool_providers=tool_providers) is None requests: list[httpx.Request] = [] def handle(request: httpx.Request) -> httpx.Response: @@ -589,10 +593,14 @@ def test_returns_tenant_client_params_when_exists( encrypter = MagicMock() encrypter.decrypt.return_value = {"client_id": "id", "client_secret": "secret"} monkeypatch.setattr( - service_module, "create_provider_encrypter", MagicMock(return_value=(encrypter, MagicMock())) + credential_module, "create_provider_encrypter", MagicMock(return_value=(encrypter, MagicMock())) ) - result = BuiltinToolManageService.get_oauth_client("tenant-1", "google") + result = BuiltinToolManageService.get_oauth_client( + "tenant-1", + "google", + tool_providers=ToolProviderRepository(sessionmaker(repository_session.get_bind(), expire_on_commit=False)), + ) assert result is not None assert result.credentials == {"client_id": "id", "client_secret": "secret"} @@ -609,13 +617,17 @@ def test_falls_back_to_system_client( controller.get_oauth_client_schema.return_value = list[object]() monkeypatch.setattr(service_module.ToolManager, "get_plugin_provider", MagicMock(return_value=controller)) monkeypatch.setattr( - service_module, "create_provider_encrypter", MagicMock(return_value=(MagicMock(), MagicMock())) + credential_module, "create_provider_encrypter", MagicMock(return_value=(MagicMock(), MagicMock())) ) - monkeypatch.setattr(service_module.PluginService, "is_plugin_verified", lambda *_args: True) + monkeypatch.setattr(credential_module.PluginService, "is_plugin_verified", lambda *_args: True) decrypt = MagicMock(return_value={"sys_key": "sys_val"}) - monkeypatch.setattr(service_module, "decrypt_system_params", decrypt) + monkeypatch.setattr(credential_module, "decrypt_system_params", decrypt) - result = BuiltinToolManageService.get_oauth_client("tenant-1", "google") + result = BuiltinToolManageService.get_oauth_client( + "tenant-1", + "google", + tool_providers=ToolProviderRepository(sessionmaker(repository_session.get_bind(), expire_on_commit=False)), + ) assert result is not None assert result.credentials == {"sys_key": "sys_val"} diff --git a/api/tests/unit_tests/services/tools/test_mcp_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_mcp_tools_manage_service.py index 42b9e7fe5f7134..95ef5320ed023f 100644 --- a/api/tests/unit_tests/services/tools/test_mcp_tools_manage_service.py +++ b/api/tests/unit_tests/services/tools/test_mcp_tools_manage_service.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import Session from models.tools import MCPToolProvider -from services.tools.legacy_mcp_tools_manage_service import MCPToolManageService +from services.tools.mcp_tools_manage_service import MCPToolManageService PROVIDER_UUID = "0b2bd1e4-3a7d-4a0b-9f2a-9b2d6f3c1e55" OTHER_PROVIDER_UUID = "00000000-0000-4000-8000-000000000002" diff --git a/api/tests/unit_tests/services/tools/test_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_tools_manage_service.py index 5967a4ac3a8228..addb036d7c8b39 100644 --- a/api/tests/unit_tests/services/tools/test_tools_manage_service.py +++ b/api/tests/unit_tests/services/tools/test_tools_manage_service.py @@ -1,40 +1,75 @@ from unittest.mock import MagicMock, patch -from services.tools.legacy_tools_manage_service import ToolCommonService +from models.tool_runtime_contracts import WorkflowToolQueries +from services.tools.provider_queries import ToolProviders +from services.tools.tools_manage_service import ToolCommonService class TestToolCommonService: - @patch("services.tools.legacy_tools_manage_service.ToolTransformService") - @patch("services.tools.legacy_tools_manage_service.ToolManager") - def test_list_tool_providers_transforms_and_returns(self, mock_manager, mock_transform): + @patch("services.tools.tools_manage_service.ToolTransformService") + @patch("services.tools.tools_manage_service.ToolManager") + def test_list_tool_providers_transforms_and_returns( + self, + mock_manager: MagicMock, + mock_transform: MagicMock, + *, + workflow_queries: WorkflowToolQueries, + tool_providers: ToolProviders, + ) -> None: mock_provider1 = MagicMock() mock_provider1.to_dict.return_value = {"name": "provider1"} mock_provider2 = MagicMock() mock_provider2.to_dict.return_value = {"name": "provider2"} mock_manager.list_providers_from_api.return_value = [mock_provider1, mock_provider2] - result = ToolCommonService.list_tool_providers("user-1", "tenant-1") + result = ToolCommonService.list_tool_providers( + "user-1", "tenant-1", workflow_queries=workflow_queries, tool_providers=tool_providers + ) - mock_manager.list_providers_from_api.assert_called_once_with("user-1", "tenant-1", None) + mock_manager.list_providers_from_api.assert_called_once_with( + "user-1", "tenant-1", None, workflow_queries=workflow_queries, tool_providers=tool_providers + ) assert mock_transform.repack_provider.call_count == 2 assert result == [{"name": "provider1"}, {"name": "provider2"}] - @patch("services.tools.legacy_tools_manage_service.ToolTransformService") - @patch("services.tools.legacy_tools_manage_service.ToolManager") - def test_list_tool_providers_with_type_filter(self, mock_manager, mock_transform): - mock_manager.list_providers_from_api.return_value = [] + @patch("services.tools.tools_manage_service.ToolTransformService") + @patch("services.tools.tools_manage_service.ToolManager") + def test_list_tool_providers_with_type_filter( + self, + mock_manager: MagicMock, + mock_transform: MagicMock, + *, + workflow_queries: WorkflowToolQueries, + tool_providers: ToolProviders, + ) -> None: + providers: list[MagicMock] = [] + mock_manager.list_providers_from_api.return_value = providers - result = ToolCommonService.list_tool_providers("user-1", "tenant-1", typ="builtin") + result = ToolCommonService.list_tool_providers( + "user-1", "tenant-1", typ="builtin", workflow_queries=workflow_queries, tool_providers=tool_providers + ) - mock_manager.list_providers_from_api.assert_called_once_with("user-1", "tenant-1", "builtin") + mock_manager.list_providers_from_api.assert_called_once_with( + "user-1", "tenant-1", "builtin", workflow_queries=workflow_queries, tool_providers=tool_providers + ) assert result == [] - @patch("services.tools.legacy_tools_manage_service.ToolTransformService") - @patch("services.tools.legacy_tools_manage_service.ToolManager") - def test_list_tool_providers_empty(self, mock_manager, mock_transform): - mock_manager.list_providers_from_api.return_value = [] + @patch("services.tools.tools_manage_service.ToolTransformService") + @patch("services.tools.tools_manage_service.ToolManager") + def test_list_tool_providers_empty( + self, + mock_manager: MagicMock, + mock_transform: MagicMock, + *, + workflow_queries: WorkflowToolQueries, + tool_providers: ToolProviders, + ) -> None: + providers: list[MagicMock] = [] + mock_manager.list_providers_from_api.return_value = providers - result = ToolCommonService.list_tool_providers("u", "t") + result = ToolCommonService.list_tool_providers( + "u", "t", workflow_queries=workflow_queries, tool_providers=tool_providers + ) assert result == [] mock_transform.repack_provider.assert_not_called() diff --git a/api/tests/unit_tests/services/tools/test_tools_transform_service.py b/api/tests/unit_tests/services/tools/test_tools_transform_service.py index 431b07dcb06f21..036487176e412b 100644 --- a/api/tests/unit_tests/services/tools/test_tools_transform_service.py +++ b/api/tests/unit_tests/services/tools/test_tools_transform_service.py @@ -1,4 +1,6 @@ -from unittest.mock import ANY, MagicMock, Mock, patch +"""Test cases for MCP tool transformation functionality.""" + +from unittest.mock import Mock, patch import pytest from pytest_mock import MockerFixture @@ -7,469 +9,424 @@ from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_entities import ( - ApiProviderAuthType, ToolParameter, ToolProviderEntity, ToolProviderIdentity, ToolProviderType, ) -from models.account import Account from models.tools import MCPToolProvider -from services.tools.legacy_tools_transform_service import ToolTransformService +from services.tools.provider_queries import MCPProviderRecord +from services.tools.tools_transform_service import ToolTransformService from tests.tool_fixtures import make_runtime_tool -"""Test cases for MCP tool transformation functionality.""" - -class TestMcpToolsTransformCases: - @staticmethod - @pytest.fixture - def mock_user(): - """Provides a real mapped account returned by the provider lookup.""" - return Account(name="Test User", email="user@example.com") - - @staticmethod - @pytest.fixture - def mock_provider(mock_user, mocker: MockerFixture): - """Provides a mock MCPToolProvider with a loaded user.""" - provider = MCPToolProvider( - name="Test Provider", - server_identifier="test-provider", - server_url="https://example.com", - server_url_hash="hash", - icon="icon", - tenant_id="tenant-id", - user_id="user-id", - ) - mocker.patch.object(provider, "load_user", return_value=mock_user) - return provider - - @staticmethod - @pytest.fixture - def mock_provider_no_user(mocker: MockerFixture): - """Provides a mock MCPToolProvider with no user.""" - provider = MCPToolProvider( - name="Test Provider", - server_identifier="test-provider", - server_url="https://example.com", - server_url_hash="hash", - icon="icon", - tenant_id="tenant-id", - user_id="user-id", - ) - mocker.patch.object(provider, "load_user", return_value=None) - return provider - - @staticmethod - @pytest.fixture - def mock_provider_full(mock_user, mocker: MockerFixture): - """Provides a fully configured mock MCPToolProvider for detailed tests.""" - provider = MCPToolProvider( - name="Test MCP Provider", - server_identifier="server-identifier-456", - server_url="https://example.com", - server_url_hash="hash", - icon="icon", - tenant_id="tenant-id", - user_id="user-id", - authed=True, - timeout=30, - sse_read_timeout=300, - ) - provider.id = "provider-id-123" - provider.provider_icon = "icon.png" - provider.masked_server_url = "https://*****.com/mcp" - provider.masked_headers = {"Authorization": "Bearer *****"} - provider.decrypted_headers = {"Authorization": "Bearer secret-token"} - - # Mock timestamp - mock_updated_at = Mock() - mock_updated_at.timestamp.return_value = 1234567890 - provider.updated_at = mock_updated_at - - mocker.patch.object(provider, "load_user", return_value=mock_user) - return provider - - @staticmethod - @pytest.fixture - def sample_mcp_tools(): - """Provides sample MCP tools for testing.""" - return { - "simple": MCPTool( - name="simple_tool", description="A simple test tool", inputSchema={"type": "object", "properties": {}} - ), - "none_desc": MCPTool( - name="tool_none_desc", description=None, inputSchema={"type": "object", "properties": {}} +@pytest.fixture +def mock_provider_full(): + """Provides a fully configured mock MCPToolProvider for detailed tests.""" + provider = MCPToolProvider( + name="Test MCP Provider", + server_identifier="server-identifier-456", + server_url="https://example.com", + server_url_hash="hash", + icon="icon", + tenant_id="tenant-id", + user_id="user-id", + authed=True, + timeout=30, + sse_read_timeout=300, + ) + provider.id = "provider-id-123" + provider.provider_icon = "icon.png" + provider.masked_server_url = "https://*****.com/mcp" + provider.masked_headers = {"Authorization": "Bearer *****"} + provider.decrypted_headers = {"Authorization": "Bearer secret-token"} + + # Mock timestamp + mock_updated_at = Mock() + mock_updated_at.timestamp.return_value = 1234567890 + provider.updated_at = mock_updated_at + + return provider + + +@pytest.fixture +def sample_mcp_tools(): + """Provides sample MCP tools for testing.""" + return { + "simple": MCPTool( + name="simple_tool", description="A simple test tool", inputSchema={"type": "object", "properties": {}} + ), + "none_desc": MCPTool(name="tool_none_desc", description=None, inputSchema={"type": "object", "properties": {}}), + "complex": MCPTool( + name="complex_tool", + description="A tool with complex parameters", + inputSchema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Input text"}, + "count": {"type": "integer", "description": "Number of items", "minimum": 1, "maximum": 100}, + "options": {"type": "array", "items": {"type": "string"}, "description": "List of options"}, + }, + "required": ["text"], + }, + ), + } + + +class TestMCPToolTransform: + """Test cases for MCP tool transformation methods.""" + + def test_mcp_tool_to_user_tool_with_none_description(self): + """Test that mcp_tool_to_user_tool handles None description correctly.""" + # Create MCP tools with None description + tools = [ + MCPTool( + name="tool1", + description=None, # This is the case that caused the error + inputSchema={"type": "object", "properties": {}}, ), - "complex": MCPTool( - name="complex_tool", - description="A tool with complex parameters", + MCPTool( + name="tool2", + description=None, inputSchema={ "type": "object", - "properties": { - "text": {"type": "string", "description": "Input text"}, - "count": {"type": "integer", "description": "Number of items", "minimum": 1, "maximum": 100}, - "options": {"type": "array", "items": {"type": "string"}, "description": "List of options"}, - }, - "required": ["text"], + "properties": {"param1": {"type": "string", "description": "A parameter"}}, }, ), + ] + + # Call the method + result = ToolTransformService.mcp_tool_to_user_tool(tools, user_name="Test User") + + # Verify the result + assert len(result) == 2 + assert all(isinstance(tool, ToolApiEntity) for tool in result) + + # Check first tool + assert result[0].name == "tool1" + assert result[0].author == "Test User" + assert isinstance(result[0].label, I18nObject) + assert result[0].label.en_US == "tool1" + assert isinstance(result[0].description, I18nObject) + assert result[0].description.en_US == "" # Should be empty string, not None + assert result[0].description.zh_Hans == "" + + # Check second tool + assert result[1].name == "tool2" + assert result[1].description.en_US == "" + assert result[1].description.zh_Hans == "" + + def test_mcp_tool_to_user_tool_with_description(self): + """Test that mcp_tool_to_user_tool handles normal description correctly.""" + # Create MCP tools with description + tools = [ + MCPTool( + name="tool_with_desc", + description="This is a test tool that does something useful", + inputSchema={"type": "object", "properties": {}}, + ) + ] + + # Call the method + result = ToolTransformService.mcp_tool_to_user_tool(tools, user_name="Test User") + + # Verify the result + assert len(result) == 1 + assert isinstance(result[0], ToolApiEntity) + assert result[0].name == "tool_with_desc" + assert result[0].description.en_US == "This is a test tool that does something useful" + assert result[0].description.zh_Hans == "This is a test tool that does something useful" + + def test_mcp_tool_to_user_tool_with_no_user(self): + """Test that mcp_tool_to_user_tool handles None user correctly.""" + # Create MCP tool + tools = [MCPTool(name="tool1", description="Test tool", inputSchema={"type": "object", "properties": {}})] + + # Call the method + result = ToolTransformService.mcp_tool_to_user_tool(tools, user_name=None) + + # Verify the result + assert len(result) == 1 + assert result[0].author == "Anonymous" + + def test_mcp_tool_to_user_tool_with_complex_schema(self, sample_mcp_tools): + """Test that mcp_tool_to_user_tool correctly converts complex input schemas.""" + # Use complex tool from fixtures + tools = [sample_mcp_tools["complex"]] + + # Call the method + result = ToolTransformService.mcp_tool_to_user_tool(tools, user_name="Test User") + + # Verify the result + assert len(result) == 1 + assert result[0].name == "complex_tool" + assert result[0].parameters is not None + # The actual parameter conversion is handled by convert_mcp_schema_to_parameter + # which should be tested separately + + def test_convert_mcp_schema_to_parameter_preserves_anyof_object_type(self): + """Nullable object schemas should keep the object parameter type.""" + schema = { + "type": "object", + "properties": { + "retrieval_model": { + "anyOf": [{"type": "object"}, {"type": "null"}], + "description": "检索模型配置", + } + }, } - class TestMCPToolTransform: - """Test cases for MCP tool transformation methods.""" - - def test_mcp_tool_to_user_tool_with_none_description(self, mock_provider): - """Test that mcp_tool_to_user_tool handles None description correctly.""" - # Create MCP tools with None description - tools = [ - MCPTool( - name="tool1", - description=None, # This is the case that caused the error - inputSchema={"type": "object", "properties": {}}, - ), - MCPTool( - name="tool2", - description=None, - inputSchema={ - "type": "object", - "properties": {"param1": {"type": "string", "description": "A parameter"}}, - }, - ), - ] - - # Call the method - result = ToolTransformService.mcp_tool_to_user_tool(mock_provider, tools) - - # Verify the result - assert len(result) == 2 - assert all(isinstance(tool, ToolApiEntity) for tool in result) - - # Check first tool - assert result[0].name == "tool1" - assert result[0].author == "Test User" - assert isinstance(result[0].label, I18nObject) - assert result[0].label.en_US == "tool1" - assert isinstance(result[0].description, I18nObject) - assert result[0].description.en_US == "" # Should be empty string, not None - assert result[0].description.zh_Hans == "" - - # Check second tool - assert result[1].name == "tool2" - assert result[1].description.en_US == "" - assert result[1].description.zh_Hans == "" - - def test_mcp_tool_to_user_tool_with_description(self, mock_provider): - """Test that mcp_tool_to_user_tool handles normal description correctly.""" - # Create MCP tools with description - tools = [ - MCPTool( - name="tool_with_desc", - description="This is a test tool that does something useful", - inputSchema={"type": "object", "properties": {}}, - ) - ] - - # Call the method - result = ToolTransformService.mcp_tool_to_user_tool(mock_provider, tools) - - # Verify the result - assert len(result) == 1 - assert isinstance(result[0], ToolApiEntity) - assert result[0].name == "tool_with_desc" - assert result[0].description.en_US == "This is a test tool that does something useful" - assert result[0].description.zh_Hans == "This is a test tool that does something useful" - - def test_mcp_tool_to_user_tool_with_no_user(self, mock_provider_no_user): - """Test that mcp_tool_to_user_tool handles None user correctly.""" - # Create MCP tool - tools = [MCPTool(name="tool1", description="Test tool", inputSchema={"type": "object", "properties": {}})] - - # Call the method - result = ToolTransformService.mcp_tool_to_user_tool(mock_provider_no_user, tools) - - # Verify the result - assert len(result) == 1 - assert result[0].author == "Anonymous" - - def test_mcp_tool_to_user_tool_with_complex_schema(self, mock_provider, sample_mcp_tools): - """Test that mcp_tool_to_user_tool correctly converts complex input schemas.""" - # Use complex tool from fixtures - tools = [sample_mcp_tools["complex"]] - - # Call the method - result = ToolTransformService.mcp_tool_to_user_tool(mock_provider, tools) - - # Verify the result - assert len(result) == 1 - assert result[0].name == "complex_tool" - assert result[0].parameters is not None - # The actual parameter conversion is handled by convert_mcp_schema_to_parameter - # which should be tested separately - - def test_convert_mcp_schema_to_parameter_preserves_anyof_object_type(self): - """Nullable object schemas should keep the object parameter type.""" - schema = { - "type": "object", - "properties": { - "retrieval_model": { - "anyOf": [{"type": "object"}, {"type": "null"}], - "description": "检索模型配置", - } - }, - } - - result = ToolTransformService.convert_mcp_schema_to_parameter(schema) - - assert len(result) == 1 - assert result[0].name == "retrieval_model" - assert result[0].type == ToolParameter.ToolParameterType.OBJECT - assert result[0].input_schema == schema["properties"]["retrieval_model"] - - def test_convert_mcp_schema_to_parameter_preserves_oneof_object_type(self): - """Nullable oneOf object schemas should keep the object parameter type.""" - schema = { - "type": "object", - "properties": { - "retrieval_model": { - "oneOf": [{"type": "object"}, {"type": "null"}], - "description": "检索模型配置", - } - }, - } - - result = ToolTransformService.convert_mcp_schema_to_parameter(schema) - - assert len(result) == 1 - assert result[0].name == "retrieval_model" - assert result[0].type == ToolParameter.ToolParameterType.OBJECT - assert result[0].input_schema == schema["properties"]["retrieval_model"] - - def test_convert_mcp_schema_to_parameter_handles_null_type(self): - """Schemas with only a null type should fall back to string.""" - schema = { - "type": "object", - "properties": { - "null_prop_str": {"type": "null"}, - "null_prop_list": {"type": ["null"]}, - }, - } - - result = ToolTransformService.convert_mcp_schema_to_parameter(schema) + result = ToolTransformService.convert_mcp_schema_to_parameter(schema) + + assert len(result) == 1 + assert result[0].name == "retrieval_model" + assert result[0].type == ToolParameter.ToolParameterType.OBJECT + assert result[0].input_schema == schema["properties"]["retrieval_model"] + + def test_convert_mcp_schema_to_parameter_preserves_oneof_object_type(self): + """Nullable oneOf object schemas should keep the object parameter type.""" + schema = { + "type": "object", + "properties": { + "retrieval_model": { + "oneOf": [{"type": "object"}, {"type": "null"}], + "description": "检索模型配置", + } + }, + } - assert len(result) == 2 - param_map = {parameter.name: parameter for parameter in result} - assert "null_prop_str" in param_map - assert param_map["null_prop_str"].type == ToolParameter.ToolParameterType.STRING - assert "null_prop_list" in param_map - assert param_map["null_prop_list"].type == ToolParameter.ToolParameterType.STRING + result = ToolTransformService.convert_mcp_schema_to_parameter(schema) + + assert len(result) == 1 + assert result[0].name == "retrieval_model" + assert result[0].type == ToolParameter.ToolParameterType.OBJECT + assert result[0].input_schema == schema["properties"]["retrieval_model"] + + def test_convert_mcp_schema_to_parameter_handles_null_type(self): + """Schemas with only a null type should fall back to string.""" + schema = { + "type": "object", + "properties": { + "null_prop_str": {"type": "null"}, + "null_prop_list": {"type": ["null"]}, + }, + } - def test_convert_mcp_schema_to_parameter_preserves_allof_object_type_with_multiple_object_items(self): - """Property-level allOf with multiple object items should still resolve to object.""" - schema = { - "type": "object", - "properties": { - "config": { - "allOf": [ - { - "type": "object", - "properties": { - "enabled": {"type": "boolean"}, - }, - "required": ["enabled"], + result = ToolTransformService.convert_mcp_schema_to_parameter(schema) + + assert len(result) == 2 + param_map = {parameter.name: parameter for parameter in result} + assert "null_prop_str" in param_map + assert param_map["null_prop_str"].type == ToolParameter.ToolParameterType.STRING + assert "null_prop_list" in param_map + assert param_map["null_prop_list"].type == ToolParameter.ToolParameterType.STRING + + def test_convert_mcp_schema_to_parameter_preserves_allof_object_type_with_multiple_object_items(self): + """Property-level allOf with multiple object items should still resolve to object.""" + schema = { + "type": "object", + "properties": { + "config": { + "allOf": [ + { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, }, - { - "type": "object", - "properties": { - "priority": {"type": "integer", "minimum": 1, "maximum": 10}, - }, - "required": ["priority"], + "required": ["enabled"], + }, + { + "type": "object", + "properties": { + "priority": {"type": "integer", "minimum": 1, "maximum": 10}, }, - ], - "description": "Config must match all schemas (allOf)", - } - }, - } - - result = ToolTransformService.convert_mcp_schema_to_parameter(schema) - - assert len(result) == 1 - assert result[0].name == "config" - assert result[0].type == ToolParameter.ToolParameterType.OBJECT - assert result[0].input_schema == schema["properties"]["config"] + "required": ["priority"], + }, + ], + "description": "Config must match all schemas (allOf)", + } + }, + } - def test_convert_mcp_schema_to_parameter_preserves_allof_object_type(self): - """Composed property schemas should keep the object parameter type.""" - schema = { - "type": "object", - "properties": { - "retrieval_model": { - "allOf": [ - {"type": "object"}, - {"properties": {"top_k": {"type": "integer"}}}, - ], - "description": "检索模型配置", - } - }, - } + result = ToolTransformService.convert_mcp_schema_to_parameter(schema) + + assert len(result) == 1 + assert result[0].name == "config" + assert result[0].type == ToolParameter.ToolParameterType.OBJECT + assert result[0].input_schema == schema["properties"]["config"] + + def test_convert_mcp_schema_to_parameter_preserves_allof_object_type(self): + """Composed property schemas should keep the object parameter type.""" + schema = { + "type": "object", + "properties": { + "retrieval_model": { + "allOf": [ + {"type": "object"}, + {"properties": {"top_k": {"type": "integer"}}}, + ], + "description": "检索模型配置", + } + }, + } - result = ToolTransformService.convert_mcp_schema_to_parameter(schema) + result = ToolTransformService.convert_mcp_schema_to_parameter(schema) + + assert len(result) == 1 + assert result[0].name == "retrieval_model" + assert result[0].type == ToolParameter.ToolParameterType.OBJECT + assert result[0].input_schema == schema["properties"]["retrieval_model"] + + def test_convert_mcp_schema_to_parameter_limits_recursive_schema_depth(self): + """Self-referential composed schemas should stop resolving after the configured max depth.""" + recursive_property: dict[str, object] = {"description": "Recursive schema"} + recursive_property["anyOf"] = [recursive_property] + schema = { + "type": "object", + "properties": { + "recursive_config": recursive_property, + }, + } - assert len(result) == 1 - assert result[0].name == "retrieval_model" - assert result[0].type == ToolParameter.ToolParameterType.OBJECT - assert result[0].input_schema == schema["properties"]["retrieval_model"] + result = ToolTransformService.convert_mcp_schema_to_parameter(schema) + + assert len(result) == 1 + assert result[0].name == "recursive_config" + assert result[0].type == ToolParameter.ToolParameterType.STRING + assert result[0].input_schema is None + + def test_mcp_provider_to_user_provider_minimal_response(self, mock_provider_full, mocker: MockerFixture): + """Test mcp_provider_to_user_provider with a response that omits optional fields.""" + # Set tools data with null description + mock_provider_full.tools = '[{"name": "tool1", "description": null, "inputSchema": {}}]' + + # Mock the to_entity and to_api_response methods + mock_entity = Mock() + mock_entity.to_api_response.return_value = { + "name": "Test MCP Provider", + "type": ToolProviderType.MCP, + "is_team_authorization": True, + "server_url": "https://*****.com/mcp", + "provider_icon": "icon.png", + "masked_headers": {"Authorization": "Bearer *****"}, + "updated_at": 1234567890, + "labels": [], + "author": "Test User", + "description": I18nObject(en_US="Test MCP Provider Description", zh_Hans="Test MCP Provider Description"), + "icon": "icon.png", + "label": I18nObject(en_US="Test MCP Provider", zh_Hans="Test MCP Provider"), + "masked_credentials": {}, + } + mocker.patch.object(mock_provider_full, "to_entity", return_value=mock_entity) - def test_convert_mcp_schema_to_parameter_limits_recursive_schema_depth(self): - """Self-referential composed schemas should stop resolving after the configured max depth.""" - recursive_property: dict[str, object] = {"description": "Recursive schema"} - recursive_property["anyOf"] = [recursive_property] - schema = { - "type": "object", - "properties": { - "recursive_config": recursive_property, - }, - } - - result = ToolTransformService.convert_mcp_schema_to_parameter(schema) - - assert len(result) == 1 - assert result[0].name == "recursive_config" - assert result[0].type == ToolParameter.ToolParameterType.STRING - assert result[0].input_schema is None - - def test_mcp_provider_to_user_provider_minimal_response(self, mock_provider_full, mocker: MockerFixture): - """Test mcp_provider_to_user_provider with a response that omits optional fields.""" - # Set tools data with null description - mock_provider_full.tools = '[{"name": "tool1", "description": null, "inputSchema": {}}]' - - # Mock the to_entity and to_api_response methods - mock_entity = Mock() - mock_entity.to_api_response.return_value = { - "name": "Test MCP Provider", - "type": ToolProviderType.MCP, - "is_team_authorization": True, - "server_url": "https://*****.com/mcp", - "provider_icon": "icon.png", - "masked_headers": {"Authorization": "Bearer *****"}, - "updated_at": 1234567890, - "labels": [], - "author": "Test User", - "description": I18nObject( - en_US="Test MCP Provider Description", zh_Hans="Test MCP Provider Description" - ), - "icon": "icon.png", - "label": I18nObject(en_US="Test MCP Provider", zh_Hans="Test MCP Provider"), - "masked_credentials": {}, - } - mocker.patch.object(mock_provider_full, "to_entity", return_value=mock_entity) - - result = ToolTransformService.mcp_provider_to_user_provider(mock_provider_full) - - # Verify the result - assert isinstance(result, ToolProviderApiEntity) - assert result.id == "provider-id-123" - assert result.name == "Test MCP Provider" - assert result.type == ToolProviderType.MCP - assert result.is_team_authorization is True - assert result.server_url == "https://*****.com/mcp" - assert len(result.tools) == 1 - assert result.tools[0].description.en_US == "" # Should handle None description - - def test_mcp_provider_to_user_provider_full_response(self, mock_provider_full, mocker: MockerFixture): - """Test mcp_provider_to_user_provider with configuration and sensitive fields.""" - # Set tools data with description - mock_provider_full.tools = '[{"name": "tool1", "description": "Tool description", "inputSchema": {}}]' - - # Mock the to_entity and to_api_response methods - mock_entity = Mock() - mock_entity.to_api_response.return_value = { - "name": "Test MCP Provider", - "type": ToolProviderType.MCP, - "is_team_authorization": True, - "server_url": "https://*****.com/mcp", - "provider_icon": "icon.png", - "masked_headers": {"Authorization": "Bearer *****"}, - "updated_at": 1234567890, - "labels": [], - "configuration": {"timeout": "30", "sse_read_timeout": "300"}, - "original_headers": {"Authorization": "Bearer secret-token"}, - "author": "Test User", - "description": I18nObject( - en_US="Test MCP Provider Description", zh_Hans="Test MCP Provider Description" - ), - "icon": "icon.png", - "label": I18nObject(en_US="Test MCP Provider", zh_Hans="Test MCP Provider"), - "masked_credentials": {}, - } - mocker.patch.object(mock_provider_full, "to_entity", return_value=mock_entity) - - result = ToolTransformService.mcp_provider_to_user_provider(mock_provider_full) - - # Verify the result - assert isinstance(result, ToolProviderApiEntity) - assert result.id == "provider-id-123" - assert result.server_identifier == "server-identifier-456" - assert result.configuration is not None - assert result.configuration.timeout == 30 - assert result.configuration.sse_read_timeout == 300 - assert result.original_headers == {"Authorization": "Bearer secret-token"} - assert len(result.tools) == 1 - assert result.tools[0].description.en_US == "Tool description" - - def test_mcp_tool_to_user_tool_falls_back_to_name_when_title_is_null(self, mock_provider): - """Tools where the server returned ``title: null`` should fall back to the tool name. - - Regression test for langgenius/dify#42453: Exa's MCP server returns - ``title: null`` for some tools, breaking tool conversion when the - server-provided title is required. - """ - tools = [ - MCPTool( - name="search_web", - title=None, - description="Search the web", - inputSchema={"type": "object", "properties": {}}, - ), - MCPTool( - name="get_content", - description="Fetch page content", - inputSchema={"type": "object", "properties": {}}, - ), - ] + result = ToolTransformService.mcp_provider_to_user_provider( + MCPProviderRecord(provider=mock_provider_full, author="Test User") + ) - result = ToolTransformService.mcp_tool_to_user_tool(mock_provider, tools) + # Verify the result + assert isinstance(result, ToolProviderApiEntity) + assert result.id == "provider-id-123" + assert result.name == "Test MCP Provider" + assert result.type == ToolProviderType.MCP + assert result.is_team_authorization is True + assert result.server_url == "https://*****.com/mcp" + assert len(result.tools) == 1 + assert result.tools[0].description.en_US == "" # Should handle None description + + def test_mcp_provider_to_user_provider_full_response(self, mock_provider_full, mocker: MockerFixture): + """Test mcp_provider_to_user_provider with configuration and sensitive fields.""" + # Set tools data with description + mock_provider_full.tools = '[{"name": "tool1", "description": "Tool description", "inputSchema": {}}]' + + # Mock the to_entity and to_api_response methods + mock_entity = Mock() + mock_entity.to_api_response.return_value = { + "name": "Test MCP Provider", + "type": ToolProviderType.MCP, + "is_team_authorization": True, + "server_url": "https://*****.com/mcp", + "provider_icon": "icon.png", + "masked_headers": {"Authorization": "Bearer *****"}, + "updated_at": 1234567890, + "labels": [], + "configuration": {"timeout": "30", "sse_read_timeout": "300"}, + "original_headers": {"Authorization": "Bearer secret-token"}, + "author": "Test User", + "description": I18nObject(en_US="Test MCP Provider Description", zh_Hans="Test MCP Provider Description"), + "icon": "icon.png", + "label": I18nObject(en_US="Test MCP Provider", zh_Hans="Test MCP Provider"), + "masked_credentials": {}, + } + mocker.patch.object(mock_provider_full, "to_entity", return_value=mock_entity) - assert len(result) == 2 - # title is explicitly null -> must fall back to ``name`` - assert result[0].name == "search_web" - assert result[0].label.en_US == "search_web" - assert result[0].label.zh_Hans == "search_web" - # title key is absent entirely -> must also fall back to ``name`` - assert result[1].name == "get_content" - assert result[1].label.en_US == "get_content" - assert result[1].label.zh_Hans == "get_content" + result = ToolTransformService.mcp_provider_to_user_provider( + MCPProviderRecord(provider=mock_provider_full, author="Test User") + ) - def test_mcp_tool_to_user_tool_uses_title_when_provided(self, mock_provider): - """When the server provides a non-null title, it should be used for the label.""" - tools = [ - MCPTool( - name="search_web", - title="Search the Web", - description="Search the web", - inputSchema={"type": "object", "properties": {}}, - ) - ] + # Verify the result + assert isinstance(result, ToolProviderApiEntity) + assert result.id == "provider-id-123" + assert result.server_identifier == "server-identifier-456" + assert result.configuration is not None + assert result.configuration.timeout == 30 + assert result.configuration.sse_read_timeout == 300 + assert result.original_headers == {"Authorization": "Bearer secret-token"} + assert len(result.tools) == 1 + assert result.tools[0].description.en_US == "Tool description" + + def test_mcp_tool_to_user_tool_falls_back_to_name_when_title_is_null(self): + """Tools where the server returned ``title: null`` should fall back to the tool name. + + Regression test for langgenius/dify#42453: Exa's MCP server returns + ``title: null`` for some tools, breaking tool conversion when the + server-provided title is required. + """ + tools = [ + MCPTool( + name="search_web", + title=None, + description="Search the web", + inputSchema={"type": "object", "properties": {}}, + ), + MCPTool( + name="get_content", + description="Fetch page content", + inputSchema={"type": "object", "properties": {}}, + ), + ] + + result = ToolTransformService.mcp_tool_to_user_tool(tools, user_name="Test User") + + assert len(result) == 2 + # title is explicitly null -> must fall back to ``name`` + assert result[0].name == "search_web" + assert result[0].label.en_US == "search_web" + assert result[0].label.zh_Hans == "search_web" + # title key is absent entirely -> must also fall back to ``name`` + assert result[1].name == "get_content" + assert result[1].label.en_US == "get_content" + assert result[1].label.zh_Hans == "get_content" + + def test_mcp_tool_to_user_tool_uses_title_when_provided(self): + """When the server provides a non-null title, it should be used for the label.""" + tools = [ + MCPTool( + name="search_web", + title="Search the Web", + description="Search the web", + inputSchema={"type": "object", "properties": {}}, + ) + ] - result = ToolTransformService.mcp_tool_to_user_tool(mock_provider, tools) + result = ToolTransformService.mcp_tool_to_user_tool(tools, user_name="Test User") - assert len(result) == 1 - assert result[0].name == "search_web" - assert result[0].label.en_US == "Search the Web" - assert result[0].label.zh_Hans == "Search the Web" + assert len(result) == 1 + assert result[0].name == "search_web" + assert result[0].label.en_US == "Search the Web" + assert result[0].label.zh_Hans == "Search the Web" -MODULE = "services.tools.legacy_tools_transform_service" +MODULE = "services.tools.tools_transform_service" def _parameter( @@ -486,486 +443,456 @@ def _parameter( ) -class TestToolsTransformServiceCases: - class TestToolTransformService: - """Test cases for ToolTransformService.convert_tool_entity_to_api_entity method""" +class TestToolTransformService: + """Test cases for ToolTransformService.convert_tool_entity_to_api_entity method""" - def test_convert_tool_with_parameter_override(self): - """Test that runtime parameters correctly override base parameters""" - base_param1 = _parameter("param1", "Base Param 1") - base_param2 = _parameter("param2", "Base Param 2") + def test_convert_tool_with_parameter_override(self): + """Test that runtime parameters correctly override base parameters""" + base_param1 = _parameter("param1", "Base Param 1") + base_param2 = _parameter("param2", "Base Param 2") - runtime_param1 = _parameter("param1", "Runtime Param 1") + runtime_param1 = _parameter("param1", "Runtime Param 1") - tool = make_runtime_tool(base_params=[base_param1, base_param2], runtime_params=[runtime_param1]) + tool = make_runtime_tool(base_params=[base_param1, base_param2], runtime_params=[runtime_param1]) - # Call the method - result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "test_tenant", None) + # Call the method + result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "test_tenant", None) - # Verify the result - assert isinstance(result, ToolApiEntity) - assert result.author == "test_author" - assert result.name == "test_tool" - assert result.parameters is not None - assert len(result.parameters) == 2 + # Verify the result + assert isinstance(result, ToolApiEntity) + assert result.author == "test_author" + assert result.name == "test_tool" + assert result.parameters is not None + assert len(result.parameters) == 2 - # Find the overridden parameter - overridden_param = next((p for p in result.parameters if p.name == "param1"), None) - assert overridden_param is not None - assert overridden_param.label.en_US == "Runtime Param 1" # Should be runtime version + # Find the overridden parameter + overridden_param = next((p for p in result.parameters if p.name == "param1"), None) + assert overridden_param is not None + assert overridden_param.label.en_US == "Runtime Param 1" # Should be runtime version - # Find the non-overridden parameter - original_param = next((p for p in result.parameters if p.name == "param2"), None) - assert original_param is not None - assert original_param.label.en_US == "Base Param 2" # Should be base version + # Find the non-overridden parameter + original_param = next((p for p in result.parameters if p.name == "param2"), None) + assert original_param is not None + assert original_param.label.en_US == "Base Param 2" # Should be base version - def test_convert_tool_with_additional_runtime_parameters(self): - """Test that additional runtime parameters are added to the final list""" - base_param1 = _parameter("param1", "Base Param 1") + def test_convert_tool_with_additional_runtime_parameters(self): + """Test that additional runtime parameters are added to the final list""" + base_param1 = _parameter("param1", "Base Param 1") - runtime_param1 = _parameter("param1", "Runtime Param 1") - runtime_param2 = _parameter("runtime_only", "Runtime Only Param") + runtime_param1 = _parameter("param1", "Runtime Param 1") + runtime_param2 = _parameter("runtime_only", "Runtime Only Param") - tool = make_runtime_tool(base_params=[base_param1], runtime_params=[runtime_param1, runtime_param2]) + tool = make_runtime_tool(base_params=[base_param1], runtime_params=[runtime_param1, runtime_param2]) - # Call the method - result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "test_tenant", None) + # Call the method + result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "test_tenant", None) - # Verify the result - assert isinstance(result, ToolApiEntity) - assert result.parameters is not None - assert len(result.parameters) == 2 + # Verify the result + assert isinstance(result, ToolApiEntity) + assert result.parameters is not None + assert len(result.parameters) == 2 - # Check that both parameters are present - param_names = [p.name for p in result.parameters] - assert "param1" in param_names - assert "runtime_only" in param_names + # Check that both parameters are present + param_names = [p.name for p in result.parameters] + assert "param1" in param_names + assert "runtime_only" in param_names - # Verify the overridden parameter has runtime version - overridden_param = next((p for p in result.parameters if p.name == "param1"), None) - assert overridden_param is not None - assert overridden_param.label.en_US == "Runtime Param 1" + # Verify the overridden parameter has runtime version + overridden_param = next((p for p in result.parameters if p.name == "param1"), None) + assert overridden_param is not None + assert overridden_param.label.en_US == "Runtime Param 1" - # Verify the new runtime parameter is included - new_param = next((p for p in result.parameters if p.name == "runtime_only"), None) - assert new_param is not None - assert new_param.label.en_US == "Runtime Only Param" + # Verify the new runtime parameter is included + new_param = next((p for p in result.parameters if p.name == "runtime_only"), None) + assert new_param is not None + assert new_param.label.en_US == "Runtime Only Param" - def test_convert_tool_with_non_form_runtime_parameters(self): - """Test that non-FORM runtime parameters are not added as new parameters""" - base_param1 = _parameter("param1", "Base Param 1") + def test_convert_tool_with_non_form_runtime_parameters(self): + """Test that non-FORM runtime parameters are not added as new parameters""" + base_param1 = _parameter("param1", "Base Param 1") - runtime_param1 = _parameter("param1", "Runtime Param 1") - runtime_param2 = _parameter("llm_param", "LLM Param", ToolParameter.ToolParameterForm.LLM) + runtime_param1 = _parameter("param1", "Runtime Param 1") + runtime_param2 = _parameter("llm_param", "LLM Param", ToolParameter.ToolParameterForm.LLM) - tool = make_runtime_tool(base_params=[base_param1], runtime_params=[runtime_param1, runtime_param2]) + tool = make_runtime_tool(base_params=[base_param1], runtime_params=[runtime_param1, runtime_param2]) - # Call the method - result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "test_tenant", None) + # Call the method + result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "test_tenant", None) - # Verify the result - assert isinstance(result, ToolApiEntity) - assert result.parameters is not None - assert len(result.parameters) == 1 # Only the FORM parameter should be present + # Verify the result + assert isinstance(result, ToolApiEntity) + assert result.parameters is not None + assert len(result.parameters) == 1 # Only the FORM parameter should be present - # Check that only the FORM parameter is present - param_names = [p.name for p in result.parameters] - assert "param1" in param_names - assert "llm_param" not in param_names + # Check that only the FORM parameter is present + param_names = [p.name for p in result.parameters] + assert "param1" in param_names + assert "llm_param" not in param_names - def test_convert_tool_with_empty_parameters(self): - """Test conversion with empty base and runtime parameters""" - tool = make_runtime_tool(base_params=[], runtime_params=[]) + def test_convert_tool_with_empty_parameters(self): + """Test conversion with empty base and runtime parameters""" + tool = make_runtime_tool(base_params=[], runtime_params=[]) - # Call the method - result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "test_tenant", None) + # Call the method + result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "test_tenant", None) - # Verify the result - assert isinstance(result, ToolApiEntity) - assert result.parameters is not None - assert len(result.parameters) == 0 + # Verify the result + assert isinstance(result, ToolApiEntity) + assert result.parameters is not None + assert len(result.parameters) == 0 - def test_convert_tool_with_none_parameters(self): - """Test conversion when base parameters is None""" - tool = make_runtime_tool(base_params=None, runtime_params=[]) + def test_convert_tool_with_none_parameters(self): + """Test conversion when base parameters is None""" + tool = make_runtime_tool(base_params=None, runtime_params=[]) - # Call the method - result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "test_tenant", None) + # Call the method + result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "test_tenant", None) - # Verify the result - assert isinstance(result, ToolApiEntity) - assert result.parameters is not None - assert len(result.parameters) == 0 + # Verify the result + assert isinstance(result, ToolApiEntity) + assert result.parameters is not None + assert len(result.parameters) == 0 - def test_convert_tool_parameter_order_preserved(self): - """Test that parameter order is preserved correctly""" - base_param1 = _parameter("param1", "Base Param 1") - base_param2 = _parameter("param2", "Base Param 2") - base_param3 = _parameter("param3", "Base Param 3") - - # Create runtime parameter that overrides middle parameter - runtime_param2 = _parameter("param2", "Runtime Param 2") + def test_convert_tool_parameter_order_preserved(self): + """Test that parameter order is preserved correctly""" + base_param1 = _parameter("param1", "Base Param 1") + base_param2 = _parameter("param2", "Base Param 2") + base_param3 = _parameter("param3", "Base Param 3") + + # Create runtime parameter that overrides middle parameter + runtime_param2 = _parameter("param2", "Runtime Param 2") - # Create new runtime parameter - runtime_param4 = _parameter("param4", "Runtime Param 4") + # Create new runtime parameter + runtime_param4 = _parameter("param4", "Runtime Param 4") - tool = make_runtime_tool( - base_params=[base_param1, base_param2, base_param3], runtime_params=[runtime_param2, runtime_param4] - ) + tool = make_runtime_tool( + base_params=[base_param1, base_param2, base_param3], runtime_params=[runtime_param2, runtime_param4] + ) - # Call the method - result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "test_tenant", None) - - # Verify the result - assert isinstance(result, ToolApiEntity) - assert result.parameters is not None - assert len(result.parameters) == 4 - - # Check that order is maintained: base parameters first, then new runtime parameters - param_names = [p.name for p in result.parameters] - assert param_names == ["param1", "param2", "param3", "param4"] - - # Verify that param2 was overridden with runtime version - param2 = result.parameters[1] - assert param2.name == "param2" - assert param2.label.en_US == "Runtime Param 2" - - class TestWorkflowProviderToUserProvider: - """Test cases for ToolTransformService.workflow_provider_to_user_provider method""" - - def test_workflow_provider_to_user_provider_with_workflow_app_id(self): - """Test that workflow_provider_to_user_provider correctly sets workflow_app_id.""" - from core.tools.workflow_as_tool.provider import WorkflowToolProviderController - - workflow_app_id = "app_123" - provider_id = "provider_123" - controller = WorkflowToolProviderController( - provider_id=provider_id, - entity=ToolProviderEntity( - # Retain legacy emoji mappings accepted by the API transformation. - identity=ToolProviderIdentity.model_construct( - author="test_author", - name="test_workflow_tool", - description=I18nObject(en_US="Test description"), - icon={"type": "emoji", "content": "🔧"}, - icon_dark=None, - label=I18nObject(en_US="Test Workflow Tool"), - ), + # Call the method + result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "test_tenant", None) + + # Verify the result + assert isinstance(result, ToolApiEntity) + assert result.parameters is not None + assert len(result.parameters) == 4 + + # Check that order is maintained: base parameters first, then new runtime parameters + param_names = [p.name for p in result.parameters] + assert param_names == ["param1", "param2", "param3", "param4"] + + # Verify that param2 was overridden with runtime version + param2 = result.parameters[1] + assert param2.name == "param2" + assert param2.label.en_US == "Runtime Param 2" + + +class TestWorkflowProviderToUserProvider: + """Test cases for ToolTransformService.workflow_provider_to_user_provider method""" + + def test_workflow_provider_to_user_provider_with_workflow_app_id(self, workflow_queries): + """Test that workflow_provider_to_user_provider correctly sets workflow_app_id.""" + from services.tools.workflow.provider import WorkflowToolProviderController + + workflow_app_id = "app_123" + provider_id = "provider_123" + controller = WorkflowToolProviderController( + provider_id=provider_id, + queries=workflow_queries, + draft_variable_saver=None, + entity=ToolProviderEntity( + # Retain legacy emoji mappings accepted by the API transformation. + identity=ToolProviderIdentity.model_construct( + author="test_author", + name="test_workflow_tool", + description=I18nObject(en_US="Test description"), + icon={"type": "emoji", "content": "🔧"}, + icon_dark=None, + label=I18nObject(en_US="Test Workflow Tool"), ), - ) + ), + ) - # Call the method - result = ToolTransformService.workflow_provider_to_user_provider( - provider_controller=controller, - labels=["label1", "label2"], - workflow_app_id=workflow_app_id, - ) + # Call the method + result = ToolTransformService.workflow_provider_to_user_provider( + provider_controller=controller, + labels=["label1", "label2"], + workflow_app_id=workflow_app_id, + ) - # Verify the result - assert isinstance(result, ToolProviderApiEntity) - assert result.id == provider_id - assert result.author == "test_author" - assert result.name == "test_workflow_tool" - assert result.type == ToolProviderType.WORKFLOW - assert result.workflow_app_id == workflow_app_id - assert result.labels == ["label1", "label2"] - assert result.is_team_authorization is True - assert result.plugin_id is None - assert result.plugin_unique_identifier is None - assert result.tools == [] - - def test_workflow_provider_to_user_provider_without_workflow_app_id(self): - """Test that workflow_provider_to_user_provider works when workflow_app_id is not provided.""" - from core.tools.workflow_as_tool.provider import WorkflowToolProviderController - - provider_id = "provider_123" - controller = WorkflowToolProviderController( - provider_id=provider_id, - entity=ToolProviderEntity( - # Retain legacy emoji mappings accepted by the API transformation. - identity=ToolProviderIdentity.model_construct( - author="test_author", - name="test_workflow_tool", - description=I18nObject(en_US="Test description"), - icon={"type": "emoji", "content": "🔧"}, - icon_dark=None, - label=I18nObject(en_US="Test Workflow Tool"), - ), + # Verify the result + assert isinstance(result, ToolProviderApiEntity) + assert result.id == provider_id + assert result.author == "test_author" + assert result.name == "test_workflow_tool" + assert result.type == ToolProviderType.WORKFLOW + assert result.workflow_app_id == workflow_app_id + assert result.labels == ["label1", "label2"] + assert result.is_team_authorization is True + assert result.plugin_id is None + assert result.plugin_unique_identifier is None + assert result.tools == [] + + def test_workflow_provider_to_user_provider_without_workflow_app_id(self, workflow_queries): + """Test that workflow_provider_to_user_provider works when workflow_app_id is not provided.""" + from services.tools.workflow.provider import WorkflowToolProviderController + + provider_id = "provider_123" + controller = WorkflowToolProviderController( + provider_id=provider_id, + queries=workflow_queries, + draft_variable_saver=None, + entity=ToolProviderEntity( + # Retain legacy emoji mappings accepted by the API transformation. + identity=ToolProviderIdentity.model_construct( + author="test_author", + name="test_workflow_tool", + description=I18nObject(en_US="Test description"), + icon={"type": "emoji", "content": "🔧"}, + icon_dark=None, + label=I18nObject(en_US="Test Workflow Tool"), ), - ) + ), + ) - # Call the method without workflow_app_id - result = ToolTransformService.workflow_provider_to_user_provider( - provider_controller=controller, - labels=["label1"], - ) + # Call the method without workflow_app_id + result = ToolTransformService.workflow_provider_to_user_provider( + provider_controller=controller, + labels=["label1"], + ) - # Verify the result - assert isinstance(result, ToolProviderApiEntity) - assert result.id == provider_id - assert result.workflow_app_id is None - assert result.labels == ["label1"] - - def test_workflow_provider_to_user_provider_workflow_app_id_none(self): - """Test that workflow_provider_to_user_provider handles None workflow_app_id explicitly.""" - from core.tools.workflow_as_tool.provider import WorkflowToolProviderController - - provider_id = "provider_123" - controller = WorkflowToolProviderController( - provider_id=provider_id, - entity=ToolProviderEntity( - # Retain legacy emoji mappings accepted by the API transformation. - identity=ToolProviderIdentity.model_construct( - author="test_author", - name="test_workflow_tool", - description=I18nObject(en_US="Test description"), - icon={"type": "emoji", "content": "🔧"}, - icon_dark=None, - label=I18nObject(en_US="Test Workflow Tool"), - ), + # Verify the result + assert isinstance(result, ToolProviderApiEntity) + assert result.id == provider_id + assert result.workflow_app_id is None + assert result.labels == ["label1"] + + def test_workflow_provider_to_user_provider_workflow_app_id_none(self, workflow_queries): + """Test that workflow_provider_to_user_provider handles None workflow_app_id explicitly.""" + from services.tools.workflow.provider import WorkflowToolProviderController + + provider_id = "provider_123" + controller = WorkflowToolProviderController( + provider_id=provider_id, + queries=workflow_queries, + draft_variable_saver=None, + entity=ToolProviderEntity( + # Retain legacy emoji mappings accepted by the API transformation. + identity=ToolProviderIdentity.model_construct( + author="test_author", + name="test_workflow_tool", + description=I18nObject(en_US="Test description"), + icon={"type": "emoji", "content": "🔧"}, + icon_dark=None, + label=I18nObject(en_US="Test Workflow Tool"), ), - ) + ), + ) - # Call the method with explicit None values - result = ToolTransformService.workflow_provider_to_user_provider( - provider_controller=controller, - labels=None, - workflow_app_id=None, - ) + # Call the method with explicit None values + result = ToolTransformService.workflow_provider_to_user_provider( + provider_controller=controller, + labels=None, + workflow_app_id=None, + ) - # Verify the result - assert isinstance(result, ToolProviderApiEntity) - assert result.id == provider_id - assert result.workflow_app_id is None - assert result.labels == [] - - def test_workflow_provider_to_user_provider_preserves_other_fields(self): - """Test that workflow_provider_to_user_provider preserves all other entity fields.""" - from core.tools.workflow_as_tool.provider import WorkflowToolProviderController - - workflow_app_id = "app_456" - provider_id = "provider_456" - controller = WorkflowToolProviderController( - provider_id=provider_id, - entity=ToolProviderEntity( - # Retain legacy emoji mappings accepted by the API transformation. - identity=ToolProviderIdentity.model_construct( - author="another_author", - name="another_workflow_tool", - description=I18nObject(en_US="Another description", zh_Hans="Another description"), - icon={"type": "emoji", "content": "⚙️"}, - icon_dark={"type": "emoji", "content": "🔧"}, - label=I18nObject(en_US="Another Workflow Tool", zh_Hans="Another Workflow Tool"), - ), + # Verify the result + assert isinstance(result, ToolProviderApiEntity) + assert result.id == provider_id + assert result.workflow_app_id is None + assert result.labels == [] + + def test_workflow_provider_to_user_provider_preserves_other_fields(self, workflow_queries): + """Test that workflow_provider_to_user_provider preserves all other entity fields.""" + from services.tools.workflow.provider import WorkflowToolProviderController + + workflow_app_id = "app_456" + provider_id = "provider_456" + controller = WorkflowToolProviderController( + provider_id=provider_id, + queries=workflow_queries, + draft_variable_saver=None, + entity=ToolProviderEntity( + # Retain legacy emoji mappings accepted by the API transformation. + identity=ToolProviderIdentity.model_construct( + author="another_author", + name="another_workflow_tool", + description=I18nObject(en_US="Another description", zh_Hans="Another description"), + icon={"type": "emoji", "content": "⚙️"}, + icon_dark={"type": "emoji", "content": "🔧"}, + label=I18nObject(en_US="Another Workflow Tool", zh_Hans="Another Workflow Tool"), ), - ) + ), + ) - # Call the method - result = ToolTransformService.workflow_provider_to_user_provider( - provider_controller=controller, - labels=["automation", "workflow"], - workflow_app_id=workflow_app_id, - ) + # Call the method + result = ToolTransformService.workflow_provider_to_user_provider( + provider_controller=controller, + labels=["automation", "workflow"], + workflow_app_id=workflow_app_id, + ) - # Verify all fields are preserved correctly - assert isinstance(result, ToolProviderApiEntity) - assert result.id == provider_id - assert result.author == "another_author" - assert result.name == "another_workflow_tool" - assert result.description.en_US == "Another description" - assert result.description.zh_Hans == "Another description" - assert result.icon == {"type": "emoji", "content": "⚙️"} - assert result.icon_dark == {"type": "emoji", "content": "🔧"} - assert result.label.en_US == "Another Workflow Tool" - assert result.label.zh_Hans == "Another Workflow Tool" - assert result.type == ToolProviderType.WORKFLOW - assert result.workflow_app_id == workflow_app_id - assert result.labels == ["automation", "workflow"] - assert result.masked_credentials == {} - assert result.is_team_authorization is True - assert result.allow_delete is True - assert result.plugin_id is None - assert result.plugin_unique_identifier is None - assert result.tools == [] - - class TestGetToolProviderIconUrl: - def test_builtin_provider_returns_console_url(self, config_overrides): - config_overrides(CONSOLE_API_URL="https://app.dify.ai") - url = ToolTransformService.get_tool_provider_icon_url("builtin", "google", "icon.png") - - assert "/builtin/google/icon" in url - assert url.startswith("https://app.dify.ai/console/api/workspaces/current/tool-provider") - - def test_builtin_provider_with_no_console_url(self, config_overrides): - config_overrides(CONSOLE_API_URL=None) - url = ToolTransformService.get_tool_provider_icon_url("builtin", "slack", "icon.png") - - assert "/builtin/slack/icon" in url - - def test_api_provider_parses_json_icon(self): - icon_json = '{"background": "#fff", "content": "A"}' - result = ToolTransformService.get_tool_provider_icon_url("api", "my-api", icon_json) - assert result == {"background": "#fff", "content": "A"} - - def test_api_provider_returns_dict_icon_directly(self): - icon = {"background": "#000", "content": "B"} - result = ToolTransformService.get_tool_provider_icon_url("api", "my-api", icon) - assert result == icon - - def test_api_provider_returns_fallback_on_invalid_json(self): - result = ToolTransformService.get_tool_provider_icon_url("api", "my-api", "not-json") - assert result == {"background": "#252525", "content": "\ud83d\ude01"} - - def test_workflow_provider_behaves_like_api(self): - icon = {"background": "#123", "content": "W"} - assert ToolTransformService.get_tool_provider_icon_url("workflow", "wf", icon) == icon - - def test_mcp_returns_icon_as_is(self): - assert ToolTransformService.get_tool_provider_icon_url("mcp", "srv", "icon-value") == "icon-value" - - def test_unknown_type_returns_empty(self): - assert ToolTransformService.get_tool_provider_icon_url("unknown", "x", "i") == "" - - class TestRepackProvider: - def test_repacks_dict_provider_icon(self): - provider = {"type": "builtin", "name": "google", "icon": "old"} - with patch.object(ToolTransformService, "get_tool_provider_icon_url", return_value="/new-url") as mock_fn: - ToolTransformService.repack_provider("t1", provider) - - assert provider["icon"] == "/new-url" - mock_fn.assert_called_once_with(provider_type="builtin", provider_name="google", icon="old") - - def test_repacks_tool_provider_api_entity_without_plugin(self): - entity = ToolProviderApiEntity( - id="slack", - author="test_author", - name="slack", - description=I18nObject(en_US="Slack"), - label=I18nObject(en_US="Slack"), - type=ToolProviderType.BUILT_IN, - plugin_id=None, - icon="icon.svg", - icon_dark="dark.svg", - ) + # Verify all fields are preserved correctly + assert isinstance(result, ToolProviderApiEntity) + assert result.id == provider_id + assert result.author == "another_author" + assert result.name == "another_workflow_tool" + assert result.description.en_US == "Another description" + assert result.description.zh_Hans == "Another description" + assert result.icon == {"type": "emoji", "content": "⚙️"} + assert result.icon_dark == {"type": "emoji", "content": "🔧"} + assert result.label.en_US == "Another Workflow Tool" + assert result.label.zh_Hans == "Another Workflow Tool" + assert result.type == ToolProviderType.WORKFLOW + assert result.workflow_app_id == workflow_app_id + assert result.labels == ["automation", "workflow"] + assert result.masked_credentials == {} + assert result.is_team_authorization is True + assert result.allow_delete is True + assert result.plugin_id is None + assert result.plugin_unique_identifier is None + assert result.tools == [] + + +class TestGetToolProviderIconUrl: + def test_builtin_provider_returns_console_url(self, config_overrides): + config_overrides(CONSOLE_API_URL="https://app.dify.ai") + url = ToolTransformService.get_tool_provider_icon_url("builtin", "google", "icon.png") + + assert "/builtin/google/icon" in url + assert url.startswith("https://app.dify.ai/console/api/workspaces/current/tool-provider") + + def test_builtin_provider_with_no_console_url(self, config_overrides): + config_overrides(CONSOLE_API_URL=None) + url = ToolTransformService.get_tool_provider_icon_url("builtin", "slack", "icon.png") + + assert "/builtin/slack/icon" in url + + def test_api_provider_parses_json_icon(self): + icon_json = '{"background": "#fff", "content": "A"}' + result = ToolTransformService.get_tool_provider_icon_url("api", "my-api", icon_json) + assert result == {"background": "#fff", "content": "A"} + + def test_api_provider_returns_dict_icon_directly(self): + icon = {"background": "#000", "content": "B"} + result = ToolTransformService.get_tool_provider_icon_url("api", "my-api", icon) + assert result == icon + + def test_api_provider_returns_fallback_on_invalid_json(self): + result = ToolTransformService.get_tool_provider_icon_url("api", "my-api", "not-json") + assert result == {"background": "#252525", "content": "\ud83d\ude01"} + + def test_workflow_provider_behaves_like_api(self): + icon = {"background": "#123", "content": "W"} + assert ToolTransformService.get_tool_provider_icon_url("workflow", "wf", icon) == icon + + def test_mcp_returns_icon_as_is(self): + assert ToolTransformService.get_tool_provider_icon_url("mcp", "srv", "icon-value") == "icon-value" + + def test_unknown_type_returns_empty(self): + assert ToolTransformService.get_tool_provider_icon_url("unknown", "x", "i") == "" + + +class TestRepackProvider: + def test_repacks_dict_provider_icon(self): + provider = {"type": "builtin", "name": "google", "icon": "old"} + with patch.object(ToolTransformService, "get_tool_provider_icon_url", return_value="/new-url") as mock_fn: + ToolTransformService.repack_provider("t1", provider) + + assert provider["icon"] == "/new-url" + mock_fn.assert_called_once_with(provider_type="builtin", provider_name="google", icon="old") + + def test_repacks_tool_provider_api_entity_without_plugin(self): + entity = ToolProviderApiEntity( + id="slack", + author="test_author", + name="slack", + description=I18nObject(en_US="Slack"), + label=I18nObject(en_US="Slack"), + type=ToolProviderType.BUILT_IN, + plugin_id=None, + icon="icon.svg", + icon_dark="dark.svg", + ) - with patch.object(ToolTransformService, "get_tool_provider_icon_url", return_value="/url"): - ToolTransformService.repack_provider("t1", entity) + with patch.object(ToolTransformService, "get_tool_provider_icon_url", return_value="/url"): + ToolTransformService.repack_provider("t1", entity) - assert entity.icon == "/url" - assert entity.icon_dark == "/url" + assert entity.icon == "/url" + assert entity.icon_dark == "/url" - class TestConvertMcpSchemaToParameter: - def test_simple_object_schema(self): - schema = { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Search query"}, - "count": {"type": "integer", "description": "Result count"}, - }, - "required": ["query"], - } - - params = ToolTransformService.convert_mcp_schema_to_parameter(schema) - - assert len(params) == 2 - query_param = next(p for p in params if p.name == "query") - count_param = next(p for p in params if p.name == "count") - assert query_param.required is True - assert count_param.required is False - assert count_param.type.value == "number" - - def test_float_maps_to_number(self): - schema = {"type": "object", "properties": {"rate": {"type": "float"}}, "required": []} - assert ToolTransformService.convert_mcp_schema_to_parameter(schema)[0].type.value == "number" - - def test_array_type_attaches_input_schema(self): - prop = {"type": "array", "description": "Items", "items": {"type": "string"}} - schema = {"type": "object", "properties": {"items": prop}, "required": []} - param = ToolTransformService.convert_mcp_schema_to_parameter(schema)[0] - assert param.input_schema is not None - - def test_non_object_schema_returns_empty(self): - assert ToolTransformService.convert_mcp_schema_to_parameter({"type": "string"}) == [] - - def test_missing_properties_returns_empty(self): - assert ToolTransformService.convert_mcp_schema_to_parameter({"type": "object"}) == [] - - def test_list_type_uses_first_element(self): - schema = {"type": "object", "properties": {"f": {"type": ["string", "null"]}}, "required": []} - assert ToolTransformService.convert_mcp_schema_to_parameter(schema)[0].type.value == "string" - - @pytest.mark.parametrize( - ("prop", "expected_type"), - [ - ({"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]}, "string"), - ({"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "string"}]}, "array"), - ({"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]}, "string"), - ({"oneOf": [{"type": "array", "items": {"type": "string"}}, {"type": "string"}]}, "array"), - ({"type": ["string", "array"], "items": {"type": "string"}}, "string"), - ({"type": ["array", "string"], "items": {"type": "string"}}, "array"), - ({"type": ["string", "array", "null"]}, "string"), - ], - ) - def test_mixed_type_union_attaches_input_schema(self, prop, expected_type): - schema = {"type": "object", "properties": {"domain": prop}, "required": []} - param = ToolTransformService.convert_mcp_schema_to_parameter(schema)[0] - assert param.type.value == expected_type - assert param.input_schema == prop - - @pytest.mark.parametrize( - "prop", - [ - {"type": ["string", "null"]}, - {"anyOf": [{"type": "string"}, {"type": "null"}]}, - {"oneOf": [{"type": "null"}, {"type": "string"}]}, - {"oneOf": [{"type": "integer"}, {"type": "number"}]}, - ], - ) - def test_single_type_union_has_no_input_schema(self, prop): - schema = {"type": "object", "properties": {"f": prop}, "required": []} - assert ToolTransformService.convert_mcp_schema_to_parameter(schema)[0].input_schema is None - - def test_missing_description_defaults_empty(self): - schema = {"type": "object", "properties": {"f": {"type": "string"}}, "required": []} - assert ToolTransformService.convert_mcp_schema_to_parameter(schema)[0].llm_description == "" - - class TestApiProviderToController: - def test_api_key_header_auth(self): - db_provider = MagicMock() - db_provider.credentials = {"auth_type": "api_key_header"} - with patch(f"{MODULE}.db.session"), patch(f"{MODULE}.ApiToolProviderController") as ctrl_cls: - ctrl_cls.from_db.return_value = MagicMock() - ToolTransformService.api_provider_to_controller(db_provider) - ctrl_cls.from_db.assert_called_once_with( - db_provider=db_provider, auth_type=ApiProviderAuthType.API_KEY_HEADER, session=ANY - ) - def test_api_key_query_auth(self): - db_provider = MagicMock() - db_provider.credentials = {"auth_type": "api_key_query"} - with patch(f"{MODULE}.db.session"), patch(f"{MODULE}.ApiToolProviderController") as ctrl_cls: - ctrl_cls.from_db.return_value = MagicMock() - ToolTransformService.api_provider_to_controller(db_provider) - ctrl_cls.from_db.assert_called_once_with( - db_provider=db_provider, auth_type=ApiProviderAuthType.API_KEY_QUERY, session=ANY - ) +class TestConvertMcpSchemaToParameter: + def test_simple_object_schema(self): + schema = { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "count": {"type": "integer", "description": "Result count"}, + }, + "required": ["query"], + } - def test_legacy_api_key_maps_to_header(self): - db_provider = MagicMock() - db_provider.credentials = {"auth_type": "api_key"} - with patch(f"{MODULE}.db.session"), patch(f"{MODULE}.ApiToolProviderController") as ctrl_cls: - ctrl_cls.from_db.return_value = MagicMock() - ToolTransformService.api_provider_to_controller(db_provider) - ctrl_cls.from_db.assert_called_once_with( - db_provider=db_provider, auth_type=ApiProviderAuthType.API_KEY_HEADER, session=ANY - ) + params = ToolTransformService.convert_mcp_schema_to_parameter(schema) + + assert len(params) == 2 + query_param = next(p for p in params if p.name == "query") + count_param = next(p for p in params if p.name == "count") + assert query_param.required is True + assert count_param.required is False + assert count_param.type.value == "number" + + def test_float_maps_to_number(self): + schema = {"type": "object", "properties": {"rate": {"type": "float"}}, "required": []} + assert ToolTransformService.convert_mcp_schema_to_parameter(schema)[0].type.value == "number" + + def test_array_type_attaches_input_schema(self): + prop = {"type": "array", "description": "Items", "items": {"type": "string"}} + schema = {"type": "object", "properties": {"items": prop}, "required": []} + param = ToolTransformService.convert_mcp_schema_to_parameter(schema)[0] + assert param.input_schema is not None + + def test_non_object_schema_returns_empty(self): + assert ToolTransformService.convert_mcp_schema_to_parameter({"type": "string"}) == [] + + def test_missing_properties_returns_empty(self): + assert ToolTransformService.convert_mcp_schema_to_parameter({"type": "object"}) == [] + + def test_list_type_uses_first_element(self): + schema = {"type": "object", "properties": {"f": {"type": ["string", "null"]}}, "required": []} + assert ToolTransformService.convert_mcp_schema_to_parameter(schema)[0].type.value == "string" + + @pytest.mark.parametrize( + ("prop", "expected_type"), + [ + ({"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]}, "string"), + ({"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "string"}]}, "array"), + ({"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]}, "string"), + ({"oneOf": [{"type": "array", "items": {"type": "string"}}, {"type": "string"}]}, "array"), + ({"type": ["string", "array"], "items": {"type": "string"}}, "string"), + ({"type": ["array", "string"], "items": {"type": "string"}}, "array"), + ({"type": ["string", "array", "null"]}, "string"), + ], + ) + def test_mixed_type_union_attaches_input_schema(self, prop, expected_type): + schema = {"type": "object", "properties": {"domain": prop}, "required": []} + param = ToolTransformService.convert_mcp_schema_to_parameter(schema)[0] + assert param.type.value == expected_type + assert param.input_schema == prop + + @pytest.mark.parametrize( + "prop", + [ + {"type": ["string", "null"]}, + {"anyOf": [{"type": "string"}, {"type": "null"}]}, + {"oneOf": [{"type": "null"}, {"type": "string"}]}, + {"oneOf": [{"type": "integer"}, {"type": "number"}]}, + ], + ) + def test_single_type_union_has_no_input_schema(self, prop): + schema = {"type": "object", "properties": {"f": prop}, "required": []} + assert ToolTransformService.convert_mcp_schema_to_parameter(schema)[0].input_schema is None - def test_unknown_auth_defaults_to_none(self): - db_provider = MagicMock() - db_provider.credentials = {"auth_type": "something_else"} - with patch(f"{MODULE}.db.session"), patch(f"{MODULE}.ApiToolProviderController") as ctrl_cls: - ctrl_cls.from_db.return_value = MagicMock() - ToolTransformService.api_provider_to_controller(db_provider) - ctrl_cls.from_db.assert_called_once_with( - db_provider=db_provider, auth_type=ApiProviderAuthType.NONE, session=ANY - ) + def test_missing_description_defaults_empty(self): + schema = {"type": "object", "properties": {"f": {"type": "string"}}, "required": []} + assert ToolTransformService.convert_mcp_schema_to_parameter(schema)[0].llm_description == ""