From 1896722e66906f102fba81bf6f1826b9b2807b91 Mon Sep 17 00:00:00 2001 From: Huixin615 Date: Tue, 16 Jun 2026 23:20:20 +0800 Subject: [PATCH] fix: add MCP tools cache reset endpoint (#3602) * fix: add MCP tools cache reset endpoint * docs: clarify MCP cache reset scope --- backend/README.md | 1 + backend/app/gateway/routers/mcp.py | 30 +++++++++++ backend/docs/API.md | 20 +++++++ backend/docs/ARCHITECTURE.md | 16 +++--- backend/tests/test_auth_middleware.py | 10 ++++ backend/tests/test_mcp_config_secrets.py | 68 ++++++++++++++++++++++++ 6 files changed, 137 insertions(+), 8 deletions(-) diff --git a/backend/README.md b/backend/README.md index 04f3f67bd..0c4724692 100644 --- a/backend/README.md +++ b/backend/README.md @@ -113,6 +113,7 @@ FastAPI application providing REST endpoints for frontend integration: |-------|---------| | `GET /api/models` | List available LLM models | | `GET/PUT /api/mcp/config` | Manage MCP server configurations | +| `POST /api/mcp/cache/reset` | Reset cached MCP tools so they reload on next use | | `GET/PUT /api/skills` | List and manage skills | | `POST /api/skills/install` | Install skill from `.skill` archive | | `GET /api/memory` | Retrieve memory data | diff --git a/backend/app/gateway/routers/mcp.py b/backend/app/gateway/routers/mcp.py index a9d2f8a50..39ae08b2d 100644 --- a/backend/app/gateway/routers/mcp.py +++ b/backend/app/gateway/routers/mcp.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Request, status from pydantic import BaseModel, Field from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config +from deerflow.mcp.cache import reset_mcp_tools_cache logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["mcp"]) @@ -69,6 +70,13 @@ class McpConfigUpdateRequest(BaseModel): ) +class McpCacheResetResponse(BaseModel): + """Response model for resetting the MCP tools cache.""" + + success: bool = Field(description="Whether the MCP tools cache was reset") + message: str = Field(description="Human-readable reset status") + + _MASKED_VALUE = "***" @@ -269,6 +277,27 @@ async def get_mcp_configuration(request: Request) -> McpConfigResponse: return McpConfigResponse(mcp_servers=servers) +@router.post( + "/mcp/cache/reset", + response_model=McpCacheResetResponse, + summary="Reset MCP Tools Cache", + description=("Reset cached MCP tools and pooled sessions process-wide so tools are reloaded on next use. This affects all threads and users in the current Gateway process."), +) +async def reset_mcp_tools_cache_endpoint(request: Request) -> McpCacheResetResponse: + """Reset cached MCP tools and persistent sessions process-wide. + + The next agent run or tool lookup will reload tools from the configured MCP + servers. This affects all threads and users in the current Gateway process, + and avoids relying on extensions_config.json mtime changes. + """ + await _require_admin_user(request) + reset_mcp_tools_cache() + return McpCacheResetResponse( + success=True, + message="MCP tools cache reset. Tools will reload on next use.", + ) + + @router.put( "/mcp/config", response_model=McpConfigResponse, @@ -363,6 +392,7 @@ async def update_mcp_configuration(request: Request, body: McpConfigUpdateReques # agent runtime lives in Gateway, so this keeps API reads and tool # execution aligned after extensions_config.json changes. reloaded_config = reload_extensions_config() + reset_mcp_tools_cache() servers = {name: _mask_server_config(McpServerConfigResponse(**server.model_dump())) for name, server in reloaded_config.mcp_servers.items()} return McpConfigResponse(mcp_servers=servers) diff --git a/backend/docs/API.md b/backend/docs/API.md index 359eecfdd..2e510c53f 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -299,6 +299,26 @@ deployment needs additional trusted launchers. } ``` +#### Reset MCP Tools Cache + +Clear cached MCP tools and persistent MCP sessions process-wide. This affects +all threads and users in the current Gateway process. Tools are loaded again +from configured MCP servers on the next agent run or tool lookup. + +```http +POST /api/mcp/cache/reset +``` + +Requires an authenticated admin session. + +**Response:** +```json +{ + "success": true, + "message": "MCP tools cache reset. Tools will reload on next use." +} +``` + ### Skills #### List Skills diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index 47859cc9c..5e25059eb 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -427,17 +427,17 @@ SKILL.md Format: ### Configuration Reload ``` -1. Client updates MCP config +1. Client updates MCP config or requests a cache reset PUT /api/mcp/config + POST /api/mcp/cache/reset -2. Gateway writes extensions_config.json - - Updates mcpServers section - - File mtime changes +2. Gateway updates runtime state + - PUT writes extensions_config.json and reloads configuration + - Both endpoints reset the MCP tools cache and persistent sessions -3. MCP Manager detects change - - get_cached_mcp_tools() checks mtime - - If changed: reinitializes MCP client - - Loads updated server configurations +3. MCP Manager reloads on next use + - get_cached_mcp_tools() lazily reinitializes MCP tools + - Loads current server configurations and tool lists 4. Next agent run uses new tools ``` diff --git a/backend/tests/test_auth_middleware.py b/backend/tests/test_auth_middleware.py index ab2e817eb..eb3cf7179 100644 --- a/backend/tests/test_auth_middleware.py +++ b/backend/tests/test_auth_middleware.py @@ -33,6 +33,7 @@ def test_public_paths(path: str): [ "/api/models", "/api/mcp/config", + "/api/mcp/cache/reset", "/api/memory", "/api/skills", "/api/threads/123", @@ -149,6 +150,10 @@ def _make_app(): async def mcp_put(): return {"ok": True} + @app.post("/api/mcp/cache/reset") + async def mcp_cache_reset(): + return {"ok": True} + @app.delete("/api/threads/abc") async def thread_delete(): return {"ok": True} @@ -360,6 +365,11 @@ def test_protected_post_no_cookie_returns_401(client): assert res.status_code == 401 +def test_mcp_cache_reset_post_no_cookie_returns_401(client): + res = client.post("/api/mcp/cache/reset") + assert res.status_code == 401 + + def test_protected_post_with_internal_auth_header_passes(): from app.gateway.internal_auth import create_internal_auth_headers diff --git a/backend/tests/test_mcp_config_secrets.py b/backend/tests/test_mcp_config_secrets.py index 59b32cfe6..1ab5e3c8f 100644 --- a/backend/tests/test_mcp_config_secrets.py +++ b/backend/tests/test_mcp_config_secrets.py @@ -12,6 +12,7 @@ from types import SimpleNamespace import pytest from fastapi import HTTPException +from app.gateway.routers import mcp as mcp_router from app.gateway.routers.mcp import ( _MCP_STDIO_COMMAND_ALLOWLIST_ENV, McpConfigUpdateRequest, @@ -21,6 +22,8 @@ from app.gateway.routers.mcp import ( _merge_preserving_secrets, _require_admin_user, _validate_mcp_update_request, + reset_mcp_tools_cache_endpoint, + update_mcp_configuration, ) # --------------------------------------------------------------------------- @@ -339,6 +342,71 @@ async def test_mcp_config_requires_admin_user(): assert exc_info.value.status_code == 403 +@pytest.mark.asyncio +async def test_reset_mcp_tools_cache_endpoint_requires_admin_user(monkeypatch): + called = False + + def fake_reset_mcp_tools_cache(): + nonlocal called + called = True + + monkeypatch.setattr(mcp_router, "reset_mcp_tools_cache", fake_reset_mcp_tools_cache) + + response = await reset_mcp_tools_cache_endpoint(_request_with_role("admin")) + + assert called is True + assert response.success is True + assert "next use" in response.message + + with pytest.raises(HTTPException) as exc_info: + await reset_mcp_tools_cache_endpoint(_request_with_role("user")) + + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_update_mcp_configuration_resets_tools_cache(monkeypatch, tmp_path): + reset_calls = 0 + config_path = tmp_path / "extensions_config.json" + config_path.write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8") + + current_config = SimpleNamespace(skills={}, mcp_servers={}) + reloaded_config = SimpleNamespace( + mcp_servers={ + "github": McpServerConfigResponse( + type="stdio", + command="npx", + args=["-y", "@modelcontextprotocol/server-github"], + ) + } + ) + + def fake_reset_mcp_tools_cache(): + nonlocal reset_calls + reset_calls += 1 + + monkeypatch.setattr(mcp_router.ExtensionsConfig, "resolve_config_path", lambda: config_path) + monkeypatch.setattr(mcp_router, "get_extensions_config", lambda: current_config) + monkeypatch.setattr(mcp_router, "reload_extensions_config", lambda: reloaded_config) + monkeypatch.setattr(mcp_router, "reset_mcp_tools_cache", fake_reset_mcp_tools_cache) + + response = await update_mcp_configuration( + _request_with_role("admin"), + McpConfigUpdateRequest( + mcp_servers={ + "github": McpServerConfigResponse( + type="stdio", + command="npx", + args=["-y", "@modelcontextprotocol/server-github"], + ) + } + ), + ) + + assert reset_calls == 1 + assert list(response.mcp_servers) == ["github"] + + def test_validate_mcp_update_allows_default_npx_stdio_command(monkeypatch): monkeypatch.delenv(_MCP_STDIO_COMMAND_ALLOWLIST_ENV, raising=False) request = McpConfigUpdateRequest(