From 16d408d908911fc3ee37b92370727102272cf885 Mon Sep 17 00:00:00 2001 From: hyl64 <78853927+hyl64@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:41:15 +0800 Subject: [PATCH] fix: refresh MCP tool metadata after updates and align App DSL test stubs (#35354) Co-authored-by: Stephen Zhou --- .../services/test_app_dsl_service.py | 91 +++++++------------ .../mcp/detail/__tests__/content.spec.tsx | 5 + .../components/tools/mcp/detail/content.tsx | 5 +- 3 files changed, 41 insertions(+), 60 deletions(-) diff --git a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py index 77ce28b999..1835650c42 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 import json from types import SimpleNamespace +from typing import Any, cast from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -17,7 +18,7 @@ from core.trigger.constants import ( ) from extensions.ext_redis import redis_client from graphon.enums import BuiltinNodeTypes -from models import Account, AppMode +from models import Account, App, AppMode from models.model import AppModelConfig, IconType from services import app_dsl_service from services.account_service import AccountService, TenantService @@ -67,6 +68,22 @@ def _pending_yaml_content(version: str = "99.0.0") -> bytes: return (f'version: "{version}"\nkind: app\napp:\n name: Loop Test\n mode: workflow\n').encode() +def _app_stub(**overrides: Any) -> App: + defaults = { + "id": str(uuid4()), + "tenant_id": _DEFAULT_TENANT_ID, + "mode": AppMode.WORKFLOW.value, + "name": "n", + "description": "d", + "icon_type": IconType.EMOJI, + "icon": "i", + "icon_background": "#fff", + "use_icon_as_answer_icon": False, + "app_model_config": None, + } + return cast(App, SimpleNamespace(**(defaults | overrides))) + + class TestAppDslService: """Integration tests for AppDslService using testcontainers.""" @@ -585,7 +602,7 @@ class TestAppDslService: def test_check_dependencies_returns_empty_when_no_redis_data(self, db_session_with_containers): service = AppDslService(db_session_with_containers) - app_model = SimpleNamespace(id=str(uuid4()), tenant_id=_DEFAULT_TENANT_ID) + app_model = _app_stub() result = service.check_dependencies(app_model=app_model) assert result.leaked_dependencies == [] @@ -614,7 +631,7 @@ class TestAppDslService: ) service = AppDslService(db_session_with_containers) - result = service.check_dependencies(app_model=SimpleNamespace(id=app_id, tenant_id=_DEFAULT_TENANT_ID)) + result = service.check_dependencies(app_model=_app_stub(id=app_id)) assert len(result.leaked_dependencies) == 1 def test_check_dependencies_with_real_app(self, db_session_with_containers, mock_external_service_dependencies): @@ -656,9 +673,7 @@ class TestAppDslService: lambda _m: SimpleNamespace(kind="conv"), ) - app = SimpleNamespace( - id=str(uuid4()), - tenant_id=_DEFAULT_TENANT_ID, + app = _app_stub( mode=AppMode.WORKFLOW.value, name="old", description="old-desc", @@ -667,7 +682,6 @@ class TestAppDslService: icon_background="#111111", updated_by=None, updated_at=None, - app_model_config=None, ) service = AppDslService(db_session_with_containers) updated = service._create_or_update_app( @@ -745,15 +759,7 @@ class TestAppDslService: service = AppDslService(db_session_with_containers) with pytest.raises(ValueError, match="Missing workflow data"): service._create_or_update_app( - app=SimpleNamespace( - id=str(uuid4()), - tenant_id=_DEFAULT_TENANT_ID, - mode=AppMode.WORKFLOW.value, - name="n", - description="d", - icon_background="#fff", - app_model_config=None, - ), + app=_app_stub(mode=AppMode.WORKFLOW.value), data={"app": {"mode": AppMode.WORKFLOW.value}}, account=_account_mock(), ) @@ -762,15 +768,7 @@ class TestAppDslService: service = AppDslService(db_session_with_containers) with pytest.raises(ValueError, match="Missing model_config"): service._create_or_update_app( - app=SimpleNamespace( - id=str(uuid4()), - tenant_id=_DEFAULT_TENANT_ID, - mode=AppMode.CHAT.value, - name="n", - description="d", - icon_background="#fff", - app_model_config=None, - ), + app=_app_stub(mode=AppMode.CHAT.value), data={"app": {"mode": AppMode.CHAT.value}}, account=_account_mock(), ) @@ -799,15 +797,7 @@ class TestAppDslService: service = AppDslService(db_session_with_containers) with pytest.raises(ValueError, match="Invalid app mode"): service._create_or_update_app( - app=SimpleNamespace( - id=str(uuid4()), - tenant_id=_DEFAULT_TENANT_ID, - mode=AppMode.RAG_PIPELINE.value, - name="n", - description="d", - icon_background="#fff", - app_model_config=None, - ), + app=_app_stub(mode=AppMode.RAG_PIPELINE.value), data={"app": {"mode": AppMode.RAG_PIPELINE.value}}, account=_account_mock(), ) @@ -828,29 +818,16 @@ class TestAppDslService: lambda *_args, **_kwargs: model_calls.append(True), ) - workflow_app = SimpleNamespace( + workflow_app = _app_stub( mode=AppMode.WORKFLOW.value, - tenant_id=_DEFAULT_TENANT_ID, - name="n", - icon="i", icon_type="emoji", - icon_background="#fff", - description="d", - use_icon_as_answer_icon=False, - app_model_config=None, ) AppDslService.export_dsl(workflow_app) assert workflow_calls == [True] - chat_app = SimpleNamespace( + chat_app = _app_stub( mode=AppMode.CHAT.value, - tenant_id=_DEFAULT_TENANT_ID, - name="n", - icon="i", icon_type="emoji", - icon_background="#fff", - description="d", - use_icon_as_answer_icon=False, app_model_config=SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": []}}), ) AppDslService.export_dsl(chat_app) @@ -863,16 +840,14 @@ class TestAppDslService: lambda **_kwargs: None, ) - emoji_app = SimpleNamespace( + emoji_app = _app_stub( mode=AppMode.WORKFLOW.value, - tenant_id=_DEFAULT_TENANT_ID, name="Emoji App", icon="🎨", icon_type=IconType.EMOJI, icon_background="#FF5733", description="App with emoji icon", use_icon_as_answer_icon=True, - app_model_config=None, ) yaml_output = AppDslService.export_dsl(emoji_app) data = yaml.safe_load(yaml_output) @@ -880,16 +855,14 @@ class TestAppDslService: assert data["app"]["icon_type"] == "emoji" assert data["app"]["icon_background"] == "#FF5733" - image_app = SimpleNamespace( + image_app = _app_stub( mode=AppMode.WORKFLOW.value, - tenant_id=_DEFAULT_TENANT_ID, name="Image App", icon="https://example.com/icon.png", icon_type=IconType.IMAGE, icon_background="#FFEAD5", description="App with image icon", use_icon_as_answer_icon=False, - app_model_config=None, ) yaml_output = AppDslService.export_dsl(image_app) data = yaml.safe_load(yaml_output) @@ -1106,7 +1079,7 @@ class TestAppDslService: export_data: dict = {} AppDslService._append_workflow_export_data( export_data=export_data, - app_model=SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID), + app_model=_app_stub(), include_secret=False, workflow_id=None, ) @@ -1132,7 +1105,7 @@ class TestAppDslService: with pytest.raises(ValueError, match="Missing draft workflow configuration"): AppDslService._append_workflow_export_data( export_data={}, - app_model=SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID), + app_model=_app_stub(), include_secret=False, workflow_id=None, ) @@ -1160,7 +1133,7 @@ class TestAppDslService: monkeypatch.setattr(app_dsl_service, "jsonable_encoder", lambda x: x) app_model_config = SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": [{"credential_id": "secret"}]}}) - app_model = SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID, app_model_config=app_model_config) + app_model = _app_stub(app_model_config=app_model_config) export_data: dict = {} AppDslService._append_model_config_export_data(export_data, app_model) @@ -1169,7 +1142,7 @@ class TestAppDslService: def test_append_model_config_export_data_requires_app_config(self): with pytest.raises(ValueError, match="Missing app configuration"): - AppDslService._append_model_config_export_data({}, SimpleNamespace(app_model_config=None)) + AppDslService._append_model_config_export_data({}, _app_stub(app_model_config=None)) # ── Dependency Extraction ───────────────────────────────────────── diff --git a/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx index f7bf8181ed..a7d5225348 100644 --- a/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx @@ -12,6 +12,7 @@ const mockAuthorizeMcp = vi.fn().mockResolvedValue({ result: 'success' }) const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' }) const mockDeleteMCP = vi.fn().mockResolvedValue({ result: 'success' }) const mockInvalidateMCPTools = vi.fn() +const mockInvalidateAllMCPTools = vi.fn() const mockOpenOAuthPopup = vi.fn() // Mutable mock state @@ -33,6 +34,7 @@ vi.mock('@/service/use-tools', () => ({ isFetching: mockIsFetching, }), useInvalidateMCPTools: () => mockInvalidateMCPTools, + useInvalidateAllMCPTools: () => mockInvalidateAllMCPTools, useUpdateMCPTools: () => ({ mutateAsync: mockUpdateTools, isPending: mockIsUpdating, @@ -180,6 +182,7 @@ describe('MCPDetailContent', () => { mockUpdateMCP.mockClear() mockDeleteMCP.mockClear() mockInvalidateMCPTools.mockClear() + mockInvalidateAllMCPTools.mockClear() mockOpenOAuthPopup.mockClear() // Reset mock return values @@ -513,6 +516,7 @@ describe('MCPDetailContent', () => { await waitFor(() => { expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1') expect(mockInvalidateMCPTools).toHaveBeenCalledWith('mcp-1') + expect(mockInvalidateAllMCPTools).toHaveBeenCalled() expect(onUpdate).toHaveBeenCalled() }) }) @@ -530,6 +534,7 @@ describe('MCPDetailContent', () => { await waitFor(() => { expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1') + expect(mockInvalidateAllMCPTools).toHaveBeenCalled() }) }) }) diff --git a/web/app/components/tools/mcp/detail/content.tsx b/web/app/components/tools/mcp/detail/content.tsx index 35c8a35a6f..c785516eee 100644 --- a/web/app/components/tools/mcp/detail/content.tsx +++ b/web/app/components/tools/mcp/detail/content.tsx @@ -26,6 +26,7 @@ import { openOAuthPopup } from '@/hooks/use-oauth' import { useAuthorizeMCP, useDeleteMCP, + useInvalidateAllMCPTools, useInvalidateMCPTools, useMCPTools, useUpdateMCP, @@ -61,6 +62,7 @@ const MCPDetailContent: FC = ({ const { data, isFetching: isGettingTools } = useMCPTools(detail.is_team_authorization ? detail.id : '') const invalidateMCPTools = useInvalidateMCPTools() + const invalidateAllMCPTools = useInvalidateAllMCPTools() const { mutateAsync: updateTools, isPending: isUpdating } = useUpdateMCPTools() const { mutateAsync: authorizeMcp, isPending: isAuthorizing } = useAuthorizeMCP() const toolList = data?.tools || [] @@ -76,8 +78,9 @@ const MCPDetailContent: FC = ({ return await updateTools(detail.id) invalidateMCPTools(detail.id) + invalidateAllMCPTools() onUpdate() - }, [detail, hideUpdateConfirm, invalidateMCPTools, onUpdate, updateTools]) + }, [detail, hideUpdateConfirm, invalidateAllMCPTools, invalidateMCPTools, onUpdate, updateTools]) const { mutateAsync: updateMCP } = useUpdateMCP({}) const { mutateAsync: deleteMCP } = useDeleteMCP({})