diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py index f5ab8b103ad..59c851d1c8c 100644 --- a/api/controllers/console/app/mcp_server.py +++ b/api/controllers/console/app/mcp_server.py @@ -1,7 +1,6 @@ import json from datetime import datetime from typing import Any -from uuid import UUID from flask_restx import Resource from pydantic import BaseModel, Field, field_validator @@ -179,11 +178,11 @@ class AppMCPServerController(Resource): return dump_response(AppMCPServerResponse, server) -@console_ns.route("/apps//server/refresh") +@console_ns.route("/apps//server/refresh") class AppMCPServerRefreshController(Resource): @console_ns.doc("refresh_app_mcp_server") @console_ns.doc(description="Refresh MCP server configuration and regenerate server code") - @console_ns.doc(params={"server_id": "Server ID"}) + @console_ns.doc(params={"app_id": "App ID"}) @console_ns.response(200, "MCP server refreshed successfully", console_ns.models[AppMCPServerResponse.__name__]) @console_ns.response(403, "Insufficient permissions") @console_ns.response(404, "Server not found") @@ -193,10 +192,11 @@ class AppMCPServerRefreshController(Resource): @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @with_current_tenant_id - def get(self, current_tenant_id: str, server_id: UUID): + @get_app_model + def post(self, current_tenant_id: str, app_model: App): server = db.session.scalar( select(AppMCPServer) - .where(AppMCPServer.id == server_id, AppMCPServer.tenant_id == current_tenant_id) + .where(AppMCPServer.app_id == app_model.id, AppMCPServer.tenant_id == current_tenant_id) .limit(1) ) if not server: diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index b2b61e4bec3..f736da7881b 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -3204,6 +3204,23 @@ Update MCP server configuration for an application | 403 | Insufficient permissions | | | 404 | Server not found | | +### [POST] /apps/{app_id}/server/refresh +Refresh MCP server configuration and regenerate server code + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | App ID | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | MCP server refreshed successfully | **application/json**: [AppMCPServerResponse](#appmcpserverresponse)
| +| 403 | Insufficient permissions | | +| 404 | Server not found | | + ### [POST] /apps/{app_id}/site Update application site configuration @@ -5140,23 +5157,6 @@ Restore a published workflow version into the draft workflow | ---- | ----------- | | 204 | API key deleted successfully | -### [GET] /apps/{server_id}/server/refresh -Refresh MCP server configuration and regenerate server code - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| server_id | path | Server ID | Yes | string (uuid) | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | MCP server refreshed successfully | **application/json**: [AppMCPServerResponse](#appmcpserverresponse)
| -| 403 | Insufficient permissions | | -| 404 | Server not found | | - ### [GET] /auth/plugin/datasource/default-list #### Responses diff --git a/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py b/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py index 1b392d5185e..9a61edd2c3f 100644 --- a/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py +++ b/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py @@ -3,10 +3,16 @@ from inspect import unwrap from types import SimpleNamespace from unittest.mock import PropertyMock, patch +import pytest from flask import Flask from controllers.console import console_ns -from controllers.console.app.mcp_server import AppMCPServerController, AppMCPServerResponse +from controllers.console.app.mcp_server import ( + AppMCPServerController, + AppMCPServerRefreshController, + AppMCPServerResponse, +) +from controllers.console.wraps import RBACPermission, RBACResourceScope class _ValidatedResponse: @@ -177,3 +183,75 @@ class TestAppMCPServerController: get_mock.assert_not_called() commit.assert_called_once() assert response == {"id": "server-1"} + + +class TestAppMCPServerRefreshController: + def test_post_refreshes_server_bound_to_app_and_tenant(self): + api = AppMCPServerRefreshController() + method = unwrap(api.post) + server = SimpleNamespace(server_code="old-code") + + with ( + patch("controllers.console.app.mcp_server.db.session.scalar", return_value=server) as scalar, + patch("controllers.console.app.mcp_server.db.session.commit") as commit, + patch("controllers.console.app.mcp_server.AppMCPServer.generate_server_code", return_value="new-code"), + patch( + "controllers.console.app.mcp_server.AppMCPServerResponse.model_validate", + return_value=_ValidatedResponse({"id": "server-1", "server_code": "new-code"}), + ), + ): + response = method(api, "tenant-1", app_model=SimpleNamespace(id="app-1")) + + stmt = scalar.call_args.args[0] + compiled = stmt.compile() + statement = str(compiled) + assert "app_mcp_servers.tenant_id" in statement + assert "app_mcp_servers.app_id" in statement + assert "tenant-1" in compiled.params.values() + assert "app-1" in compiled.params.values() + assert server.server_code == "new-code" + commit.assert_called_once() + assert response == {"id": "server-1", "server_code": "new-code"} + + def test_route_is_app_scoped_post(self): + route_map = { + resource.__name__: urls + for resource, urls, _route_doc, _kwargs in console_ns.resources + if resource.__name__ == "AppMCPServerRefreshController" + } + + assert route_map["AppMCPServerRefreshController"] == ("/apps//server/refresh",) + assert hasattr(AppMCPServerRefreshController, "post") + assert not hasattr(AppMCPServerRefreshController, "get") + + def test_post_requires_app_view_layout_permission(self): + method = AppMCPServerRefreshController.post + while "rbac_permission_required" not in method.__code__.co_qualname: + method = method.__wrapped__ + + class PermissionCheckedError(Exception): + pass + + current_user = SimpleNamespace(id="account-1") + with ( + patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True), + patch( + "controllers.common.wraps.current_account_with_tenant", + return_value=(current_user, "tenant-1"), + ), + patch( + "controllers.common.wraps.enforce_rbac_access", + side_effect=PermissionCheckedError, + ) as enforce_rbac_access, + pytest.raises(PermissionCheckedError), + ): + method(AppMCPServerRefreshController(), app_id="app-1") + + enforce_rbac_access.assert_called_once_with( + tenant_id="tenant-1", + account_id="account-1", + resource_type=RBACResourceScope.APP, + scene=RBACPermission.APP_VIEW_LAYOUT, + resource_required=True, + path_args={"app_id": "app-1"}, + ) diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 9f0a504d9be..b22e0a56886 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -272,8 +272,6 @@ import { zGetAppsByAppIdWorkflowsTriggersWebhookResponse, zGetAppsByResourceIdApiKeysPath, zGetAppsByResourceIdApiKeysResponse, - zGetAppsByServerIdServerRefreshPath, - zGetAppsByServerIdServerRefreshResponse, zGetAppsImportsByAppIdCheckDependenciesPath, zGetAppsImportsByAppIdCheckDependenciesResponse, zGetAppsQuery, @@ -375,6 +373,8 @@ import { zPostAppsByAppIdPublishToCreatorsPlatformResponse, zPostAppsByAppIdServerBody, zPostAppsByAppIdServerPath, + zPostAppsByAppIdServerRefreshPath, + zPostAppsByAppIdServerRefreshResponse, zPostAppsByAppIdServerResponse, zPostAppsByAppIdSiteAccessTokenResetPath, zPostAppsByAppIdSiteAccessTokenResetResponse, @@ -2290,6 +2290,25 @@ export const publishToCreatorsPlatform = { post: post30, } +/** + * Refresh MCP server configuration and regenerate server code + */ +export const post31 = oc + .route({ + description: 'Refresh MCP server configuration and regenerate server code', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdServerRefresh', + path: '/apps/{app_id}/server/refresh', + tags: ['console'], + }) + .input(z.object({ params: zPostAppsByAppIdServerRefreshPath })) + .output(zPostAppsByAppIdServerRefreshResponse) + +export const refresh = { + post: post31, +} + /** * Get MCP server configuration for an application */ @@ -2308,7 +2327,7 @@ export const get39 = oc /** * Create MCP server configuration for an application */ -export const post31 = oc +export const post32 = oc .route({ description: 'Create MCP server configuration for an application', inputStructure: 'detailed', @@ -2338,14 +2357,15 @@ export const put = oc export const server = { get: get39, - post: post31, + post: post32, put, + refresh, } /** * Reset access token for application site */ -export const post32 = oc +export const post33 = oc .route({ description: 'Reset access token for application site', inputStructure: 'detailed', @@ -2358,13 +2378,13 @@ export const post32 = oc .output(zPostAppsByAppIdSiteAccessTokenResetResponse) export const accessTokenReset = { - post: post32, + post: post33, } /** * Update application site configuration */ -export const post33 = oc +export const post34 = oc .route({ description: 'Update application site configuration', inputStructure: 'detailed', @@ -2377,14 +2397,14 @@ export const post33 = oc .output(zPostAppsByAppIdSiteResponse) export const site = { - post: post33, + post: post34, accessTokenReset, } /** * Enable or disable app site */ -export const post34 = oc +export const post35 = oc .route({ description: 'Enable or disable app site', inputStructure: 'detailed', @@ -2397,7 +2417,7 @@ export const post34 = oc .output(zPostAppsByAppIdSiteEnableResponse) export const siteEnable = { - post: post34, + post: post35, } /** @@ -2418,7 +2438,7 @@ export const delete9 = oc /** * Star an application for the current account */ -export const post35 = oc +export const post36 = oc .route({ description: 'Star an application for the current account', inputStructure: 'detailed', @@ -2432,7 +2452,7 @@ export const post35 = oc export const star = { delete: delete9, - post: post35, + post: post36, } /** @@ -2665,7 +2685,7 @@ export const voices = { /** * Convert text to speech for chat messages */ -export const post36 = oc +export const post37 = oc .route({ description: 'Convert text to speech for chat messages', inputStructure: 'detailed', @@ -2680,7 +2700,7 @@ export const post36 = oc .output(zPostAppsByAppIdTextToAudioResponse) export const textToAudio = { - post: post36, + post: post37, voices, } @@ -2705,7 +2725,7 @@ export const get49 = oc /** * Update app tracing configuration */ -export const post37 = oc +export const post38 = oc .route({ description: 'Update app tracing configuration', inputStructure: 'detailed', @@ -2719,7 +2739,7 @@ export const post37 = oc export const trace = { get: get49, - post: post37, + post: post38, } /** @@ -2788,7 +2808,7 @@ export const patch = oc * * Create a new tracing configuration for an application */ -export const post38 = oc +export const post39 = oc .route({ description: 'Create a new tracing configuration for an application', inputStructure: 'detailed', @@ -2808,13 +2828,13 @@ export const traceConfig = { delete: delete10, get: get50, patch, - post: post38, + post: post39, } /** * Update app trigger (enable/disable) */ -export const post39 = oc +export const post40 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2832,7 +2852,7 @@ export const post39 = oc .output(zPostAppsByAppIdTriggerEnableResponse) export const triggerEnable = { - post: post39, + post: post40, } /** @@ -2940,7 +2960,7 @@ export const count3 = { * * Stop running workflow task */ -export const post40 = oc +export const post41 = oc .route({ description: 'Stop running workflow task', inputStructure: 'detailed', @@ -2954,7 +2974,7 @@ export const post40 = oc .output(zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse) export const stop3 = { - post: post40, + post: post41, } export const byTaskId3 = { @@ -3057,7 +3077,7 @@ export const read = { /** * Upload one workflow Agent sandbox file and return a signed download URL */ -export const post41 = oc +export const post42 = oc .route({ description: 'Upload one workflow Agent sandbox file and return a signed download URL', inputStructure: 'detailed', @@ -3075,7 +3095,7 @@ export const post41 = oc .output(zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse) export const upload3 = { - post: post41, + post: post42, } /** @@ -3225,7 +3245,7 @@ export const byReplyId = { * * Add a reply to a workflow comment */ -export const post42 = oc +export const post43 = oc .route({ description: 'Add a reply to a workflow comment', inputStructure: 'detailed', @@ -3245,7 +3265,7 @@ export const post42 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse) export const replies = { - post: post42, + post: post43, byReplyId, } @@ -3254,7 +3274,7 @@ export const replies = { * * Resolve a workflow comment */ -export const post43 = oc +export const post44 = oc .route({ description: 'Resolve a workflow comment', inputStructure: 'detailed', @@ -3268,7 +3288,7 @@ export const post43 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse) export const resolve = { - post: post43, + post: post44, } /** @@ -3362,7 +3382,7 @@ export const get63 = oc * * Create a new workflow comment */ -export const post44 = oc +export const post45 = oc .route({ description: 'Create a new workflow comment', inputStructure: 'detailed', @@ -3383,7 +3403,7 @@ export const post44 = oc export const comments = { get: get63, - post: post44, + post: post45, mentionUsers, byCommentId, } @@ -3564,7 +3584,7 @@ export const get70 = oc /** * Update conversation variables for workflow draft */ -export const post45 = oc +export const post46 = oc .route({ description: 'Update conversation variables for workflow draft', inputStructure: 'detailed', @@ -3583,7 +3603,7 @@ export const post45 = oc export const conversationVariables2 = { get: get70, - post: post45, + post: post46, } /** @@ -3607,7 +3627,7 @@ export const get71 = oc /** * Update environment variables for workflow draft */ -export const post46 = oc +export const post47 = oc .route({ description: 'Update environment variables for workflow draft', inputStructure: 'detailed', @@ -3626,13 +3646,13 @@ export const post46 = oc export const environmentVariables = { get: get71, - post: post46, + post: post47, } /** * Update draft workflow features */ -export const post47 = oc +export const post48 = oc .route({ description: 'Update draft workflow features', inputStructure: 'detailed', @@ -3650,7 +3670,7 @@ export const post47 = oc .output(zPostAppsByAppIdWorkflowsDraftFeaturesResponse) export const features = { - post: post47, + post: post48, } /** @@ -3658,7 +3678,7 @@ export const features = { * * Test human input delivery for workflow */ -export const post48 = oc +export const post49 = oc .route({ description: 'Test human input delivery for workflow', inputStructure: 'detailed', @@ -3677,7 +3697,7 @@ export const post48 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse) export const deliveryTest = { - post: post48, + post: post49, } /** @@ -3685,7 +3705,7 @@ export const deliveryTest = { * * Get human input form preview for workflow */ -export const post49 = oc +export const post50 = oc .route({ description: 'Get human input form preview for workflow', inputStructure: 'detailed', @@ -3704,7 +3724,7 @@ export const post49 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse) export const preview5 = { - post: post49, + post: post50, } /** @@ -3712,7 +3732,7 @@ export const preview5 = { * * Submit human input form preview for workflow */ -export const post50 = oc +export const post51 = oc .route({ description: 'Submit human input form preview for workflow', inputStructure: 'detailed', @@ -3731,7 +3751,7 @@ export const post50 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse) export const run5 = { - post: post50, + post: post51, } export const form2 = { @@ -3757,7 +3777,7 @@ export const humanInput2 = { * * Run draft workflow iteration node */ -export const post51 = oc +export const post52 = oc .route({ description: 'Run draft workflow iteration node', inputStructure: 'detailed', @@ -3776,7 +3796,7 @@ export const post51 = oc .output(zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse) export const run6 = { - post: post51, + post: post52, } export const byNodeId6 = { @@ -3796,7 +3816,7 @@ export const iteration2 = { * * Run draft workflow loop node */ -export const post52 = oc +export const post53 = oc .route({ description: 'Run draft workflow loop node', inputStructure: 'detailed', @@ -3815,7 +3835,7 @@ export const post52 = oc .output(zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse) export const run7 = { - post: post52, + post: post53, } export const byNodeId7 = { @@ -3847,7 +3867,7 @@ export const candidates = { get: get72, } -export const post53 = oc +export const post54 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3864,10 +3884,10 @@ export const post53 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse) export const copyFromRoster = { - post: post53, + post: post54, } -export const post54 = oc +export const post55 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3884,10 +3904,10 @@ export const post54 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse) export const impact = { - post: post54, + post: post55, } -export const post55 = oc +export const post56 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3904,10 +3924,10 @@ export const post55 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse) export const saveToRoster = { - post: post55, + post: post56, } -export const post56 = oc +export const post57 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3924,7 +3944,7 @@ export const post56 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse) export const validate = { - post: post56, + post: post57, } export const get73 = oc @@ -3993,7 +4013,7 @@ export const lastRun = { * * Run draft workflow node */ -export const post57 = oc +export const post58 = oc .route({ description: 'Run draft workflow node', inputStructure: 'detailed', @@ -4012,7 +4032,7 @@ export const post57 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse) export const run8 = { - post: post57, + post: post58, } /** @@ -4020,7 +4040,7 @@ export const run8 = { * * Poll for trigger events and execute single node when event arrives */ -export const post58 = oc +export const post59 = oc .route({ description: 'Poll for trigger events and execute single node when event arrives', inputStructure: 'detailed', @@ -4034,7 +4054,7 @@ export const post58 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse) export const run9 = { - post: post58, + post: post59, } export const trigger = { @@ -4094,7 +4114,7 @@ export const nodes7 = { * * Run draft workflow */ -export const post59 = oc +export const post60 = oc .route({ description: 'Run draft workflow', inputStructure: 'detailed', @@ -4113,7 +4133,7 @@ export const post59 = oc .output(zPostAppsByAppIdWorkflowsDraftRunResponse) export const run10 = { - post: post59, + post: post60, } /** @@ -4235,7 +4255,7 @@ export const systemVariables = { * * Poll for trigger events and execute full workflow when event arrives */ -export const post60 = oc +export const post61 = oc .route({ description: 'Poll for trigger events and execute full workflow when event arrives', inputStructure: 'detailed', @@ -4254,7 +4274,7 @@ export const post60 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse) export const run11 = { - post: post60, + post: post61, } /** @@ -4262,7 +4282,7 @@ export const run11 = { * * Full workflow debug when the start node is a trigger */ -export const post61 = oc +export const post62 = oc .route({ description: 'Full workflow debug when the start node is a trigger', inputStructure: 'detailed', @@ -4281,7 +4301,7 @@ export const post61 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse) export const runAll = { - post: post61, + post: post62, } export const trigger2 = { @@ -4434,7 +4454,7 @@ export const get83 = oc * * Sync draft workflow configuration */ -export const post62 = oc +export const post63 = oc .route({ description: 'Sync draft workflow configuration', inputStructure: 'detailed', @@ -4454,7 +4474,7 @@ export const post62 = oc export const draft2 = { get: get83, - post: post62, + post: post63, conversationVariables: conversationVariables2, environmentVariables, features, @@ -4490,7 +4510,7 @@ export const get84 = oc /** * Publish workflow */ -export const post63 = oc +export const post64 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4509,7 +4529,7 @@ export const post63 = oc export const publish = { get: get84, - post: post63, + post: post64, } /** @@ -4646,7 +4666,7 @@ export const triggers2 = { /** * Restore a published workflow version into the draft workflow */ -export const post64 = oc +export const post65 = oc .route({ description: 'Restore a published workflow version into the draft workflow', inputStructure: 'detailed', @@ -4659,7 +4679,7 @@ export const post64 = oc .output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post64, + post: post65, } /** @@ -4884,7 +4904,7 @@ export const get92 = oc * * Create a new API key for an app */ -export const post65 = oc +export const post66 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -4900,7 +4920,7 @@ export const post65 = oc export const apiKeys = { get: get92, - post: post65, + post: post66, byApiKeyId, } @@ -4908,39 +4928,12 @@ export const byResourceId = { apiKeys, } -/** - * Refresh MCP server configuration and regenerate server code - */ -export const get93 = oc - .route({ - description: 'Refresh MCP server configuration and regenerate server code', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByServerIdServerRefresh', - path: '/apps/{server_id}/server/refresh', - tags: ['console'], - }) - .input(z.object({ params: zGetAppsByServerIdServerRefreshPath })) - .output(zGetAppsByServerIdServerRefreshResponse) - -export const refresh = { - get: get93, -} - -export const server2 = { - refresh, -} - -export const byServerId = { - server: server2, -} - /** * Get app list * * Get list of applications with pagination and filtering */ -export const get94 = oc +export const get93 = oc .route({ description: 'Get list of applications with pagination and filtering', inputStructure: 'detailed', @@ -4958,7 +4951,7 @@ export const get94 = oc * * Create a new application */ -export const post66 = oc +export const post67 = oc .route({ description: 'Create a new application', inputStructure: 'detailed', @@ -4973,15 +4966,14 @@ export const post66 = oc .output(zPostAppsResponse) export const apps = { - get: get94, - post: post66, + get: get93, + post: post67, imports, recent, starred, workflows, byAppId: byAppId2, byResourceId, - byServerId, } export const contract = { diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index cfff4e2be85..e2d787d6c98 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -5010,6 +5010,27 @@ export type PutAppsByAppIdServerResponses = { export type PutAppsByAppIdServerResponse = PutAppsByAppIdServerResponses[keyof PutAppsByAppIdServerResponses] +export type PostAppsByAppIdServerRefreshData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/server/refresh' +} + +export type PostAppsByAppIdServerRefreshErrors = { + 403: unknown + 404: unknown +} + +export type PostAppsByAppIdServerRefreshResponses = { + 200: AppMcpServerResponse +} + +export type PostAppsByAppIdServerRefreshResponse = + PostAppsByAppIdServerRefreshResponses[keyof PostAppsByAppIdServerRefreshResponses] + export type PostAppsByAppIdSiteData = { body: AppSiteUpdatePayload path: { @@ -7001,24 +7022,3 @@ export type DeleteAppsByResourceIdApiKeysByApiKeyIdResponses = { export type DeleteAppsByResourceIdApiKeysByApiKeyIdResponse = DeleteAppsByResourceIdApiKeysByApiKeyIdResponses[keyof DeleteAppsByResourceIdApiKeysByApiKeyIdResponses] - -export type GetAppsByServerIdServerRefreshData = { - body?: never - path: { - server_id: string - } - query?: never - url: '/apps/{server_id}/server/refresh' -} - -export type GetAppsByServerIdServerRefreshErrors = { - 403: unknown - 404: unknown -} - -export type GetAppsByServerIdServerRefreshResponses = { - 200: AppMcpServerResponse -} - -export type GetAppsByServerIdServerRefreshResponse = - GetAppsByServerIdServerRefreshResponses[keyof GetAppsByServerIdServerRefreshResponses] diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 8bdbda8172a..c58c2c8cb76 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -5527,6 +5527,15 @@ export const zPutAppsByAppIdServerPath = z.object({ */ export const zPutAppsByAppIdServerResponse = zAppMcpServerResponse +export const zPostAppsByAppIdServerRefreshPath = z.object({ + app_id: z.uuid(), +}) + +/** + * MCP server refreshed successfully + */ +export const zPostAppsByAppIdServerRefreshResponse = zAppMcpServerResponse + export const zPostAppsByAppIdSiteBody = zAppSiteUpdatePayload export const zPostAppsByAppIdSitePath = z.object({ @@ -6772,12 +6781,3 @@ export const zDeleteAppsByResourceIdApiKeysByApiKeyIdPath = z.object({ * API key deleted successfully */ export const zDeleteAppsByResourceIdApiKeysByApiKeyIdResponse = z.void() - -export const zGetAppsByServerIdServerRefreshPath = z.object({ - server_id: z.uuid(), -}) - -/** - * MCP server refreshed successfully - */ -export const zGetAppsByServerIdServerRefreshResponse = zAppMcpServerResponse diff --git a/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx b/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx index 45f5824c46d..6b2fc1993ac 100644 --- a/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx @@ -152,7 +152,7 @@ describe('MCPServiceCard', () => { expect(screen.getByRole('switch')).toBeInTheDocument() }) - it('should keep status visible and disable management controls without mcp.manage', () => { + it('should keep status visible and disable management controls without app edit permission', () => { mockHookState = createDefaultHookState({ canManageMCP: false, toggleDisabled: true, diff --git a/web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts index 595ad03971e..ea43cf7bb8b 100644 --- a/web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts +++ b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts @@ -166,7 +166,7 @@ describe('useMCPServiceCardState', () => { describe('Permission Flags', () => { it('should expose MCP manage capability from app edit ACL', () => { - const appInfo = createMockAppInfo() + const appInfo = createMockAppInfo(AppModeEnum.CHAT, [AppACLPermission.Edit]) const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { wrapper: createWrapper(), }) @@ -320,7 +320,7 @@ describe('useMCPServiceCardState', () => { }) describe('Handler Functions', () => { - it('should call handleGenCode and invalidate server detail', async () => { + it('should refresh the MCP server by app ID and invalidate server detail', async () => { const appInfo = createMockAppInfo() const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { wrapper: createWrapper(), @@ -330,8 +330,8 @@ describe('useMCPServiceCardState', () => { await result.current.handleGenCode() }) - // handleGenCode should complete without error - expect(result.current.genLoading).toBe(false) + expect(mockRefreshMCPServerCode).toHaveBeenCalledWith('app-123') + expect(mockInvalidateMCPServerDetail).toHaveBeenCalledWith('app-123') }) it('should call invalidateBasicAppConfig', () => { diff --git a/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts b/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts index 967fcbe63ec..b8acd9ff1f8 100644 --- a/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts +++ b/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts @@ -122,9 +122,9 @@ export const useMCPServiceCardState = (appInfo: AppInfo, triggerModeDisabled: bo const handleGenCode = useCallback(async () => { if (!canManageMCP) return - await refreshMCPServerCode(detail?.id || '') + await refreshMCPServerCode(appId) invalidateMCPServerDetail(appId) - }, [canManageMCP, refreshMCPServerCode, detail?.id, invalidateMCPServerDetail, appId]) + }, [canManageMCP, refreshMCPServerCode, invalidateMCPServerDetail, appId]) const handleStatusChange = useCallback( async (state: boolean) => { diff --git a/web/service/__tests__/use-tools.spec.tsx b/web/service/__tests__/use-tools.spec.tsx new file mode 100644 index 00000000000..cc91369dfe9 --- /dev/null +++ b/web/service/__tests__/use-tools.spec.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook } from '@/test/console/render' +import { useRefreshMCPServerCode } from '../use-tools' + +const { mockGet, mockPost } = vi.hoisted(() => ({ + mockGet: vi.fn(), + mockPost: vi.fn(), +})) + +vi.mock('@/service/base', () => ({ + del: vi.fn(), + get: mockGet, + post: mockPost, + put: vi.fn(), +})) + +vi.mock('@/service/client', () => ({ + consoleClient: { + apps: { + byAppId: { + server: { + refresh: { + post: mockPost, + }, + }, + }, + }, + }, +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { + retry: false, + }, + }, + }) + + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +describe('useRefreshMCPServerCode', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('refreshes the MCP server with POST and the app ID', async () => { + mockPost.mockResolvedValue({ id: 'server-1', server_code: 'new-code' }) + const { result } = renderHook(() => useRefreshMCPServerCode(), { + wrapper: createWrapper(), + }) + + await act(async () => { + await result.current.mutateAsync('app-1') + }) + + expect(mockPost).toHaveBeenCalledWith({ + params: { + app_id: 'app-1', + }, + }) + expect(mockGet).not.toHaveBeenCalled() + }) +}) diff --git a/web/service/use-tools.ts b/web/service/use-tools.ts index 772e813df51..55829c307d9 100644 --- a/web/service/use-tools.ts +++ b/web/service/use-tools.ts @@ -11,6 +11,7 @@ import type { AppIconType } from '@/types/app' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { CollectionType } from '@/app/components/tools/types' import { del, get, post, put } from './base' +import { consoleClient } from './client' import { useInvalid } from './use-base' const NAME_SPACE = 'tools' @@ -250,7 +251,11 @@ export const useRefreshMCPServerCode = () => { return useMutation({ mutationKey: [NAME_SPACE, 'refresh-mcp-server-code'], mutationFn: (appID: string) => { - return get(`apps/${appID}/server/refresh`) + return consoleClient.apps.byAppId.server.refresh.post({ + params: { + app_id: appID, + }, + }) }, }) }