fix: isolate MCP server toggles from invalid peer configs (#4577)

* fix: isolate MCP server toggle updates

* fix: write extensions config atomically

* fix: normalize MCP transport aliases
This commit is contained in:
Huixin615 2026-07-31 08:32:39 +08:00 committed by GitHub
parent 150f7740c7
commit 133a82c6c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 908 additions and 82 deletions

View File

@ -245,6 +245,14 @@ This section accumulates work toward the **2.1.0** milestone
### Fixed
- **mcp:** Isolate Settings > Tools enable/disable updates to one MCP server, so
an unrelated disallowed stdio command no longer blocks every switch; allow
disabling a disallowed target while still rejecting its re-enable, preserve
the raw extensions config, honor the MCP-spec `transport` alias when enabling
SSE/HTTP servers, surface backend validation details in the UI, and atomically
replace the shared config for MCP, skill, and embedded-client updates so
interrupted writes cannot leave it truncated.
([#4574])
- **runtime:** Thread metadata now switches to `running` only after the run passes
the startup barrier, so pending-cancelled runs no longer briefly project
`running`; clients may observe the prior thread status during worker startup.

View File

@ -401,6 +401,9 @@ See the [Sandbox Configuration Guide](backend/docs/CONFIGURATION.md#sandbox) to
DeerFlow supports configurable MCP servers and skills to extend its capabilities.
For HTTP/SSE MCP servers, OAuth token flows are supported (`client_credentials`, `refresh_token`).
For stdio MCP servers, per-tool call timeouts can be configured with `tool_call_timeout`.
Settings > Tools updates one MCP server at a time: an invalid stdio command on one server no longer blocks toggling another, while enabling that invalid server remains protected by the command allowlist and surfaces the backend validation message in the UI.
Targeted updates accept both DeerFlow's `type` field and the MCP-spec `transport` field for SSE/HTTP servers.
Runtime MCP and skill updates replace `extensions_config.json` atomically, so an interrupted write cannot leave the shared configuration truncated or partially written.
MCP routing hints can also prefer a specific MCP tool for matching requests without forbidding other tools. When `tool_search` defers MCP schemas, matching routing metadata can auto-promote up to `tool_search.auto_promote_top_k` deferred schemas before the model call.
See the [MCP Server Guide](backend/docs/MCP_SERVER.md) for detailed instructions.

View File

@ -486,7 +486,7 @@ Localhost persistence deliberately reads the direct request `Host` and ignores `
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
| **Features** (`/api/features`) | `GET /` - report config-gated feature availability (`agents_api.enabled`, `browser_control.enabled`) for frontend UI gating |
| **Console** (`/api/console`) | Read-only cross-thread observability for the current user (the data layer for an operations dashboard or external monitoring): `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly as a reporting layer (no new `RunStore` methods); requires a SQL database backend — returns 503 on `database.backend: memory`. Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector``record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). All priced models must use one currency; mixed currencies disable cost reporting and leave cost/currency fields null instead of producing invalid aggregates. Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured |
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - update config (saves to extensions_config.json) |
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - replace the full config with whole-payload stdio validation; `PATCH /config` - toggle one server while preserving the raw extensions config and validating only an enabled target; both writes reload config and reset the process-local MCP cache |
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`); `POST /reload` - admin-only process-local prompt-cache invalidation after trusted external filesystem changes |
| **Integrations** (`/api/integrations`) | `GET /lark/status` - inspect managed Lark/Feishu CLI integration state, including `sandbox_runtime_mode` / `sandbox_runtime_ready` (whether `lark-cli` will actually be present in the sandbox at chat time); `POST /lark/install` - admin-only install of the official `lark-*` managed skill pack; `POST /lark/config/start` and `/lark/config/complete` - internal first-time Lark connection setup; `POST /lark/auth/start` and `/lark/auth/complete` - browser device-flow user authorization without terminal access, with optional `domains` / exact `scope` for incremental permission grants |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
@ -752,7 +752,7 @@ E2B output sync records remote file versions and actual host file metadata in a
add a parallel routing middleware for PR1-style preference hints.
- **Stdio file outputs**: Persistent stdio sessions are scoped by `user_id:thread_id`. For stdio transports only, DeerFlow pins the subprocess default `cwd` to the thread workspace and `TMPDIR`/`TMP`/`TEMP` to `workspace/.mcp/tmp/`, unless the operator explicitly configured `cwd` or temp env values. SSE/HTTP transports skip this filesystem prep entirely.
- **Stdio path translation**: MCP-returned local file references are not copied. If a `ResourceLink` or conservative free-text path resolves to an existing file inside the thread's mounted user-data tree, it is translated deterministically to `/mnt/user-data/...`; paths outside that tree remain unchanged.
- **Runtime updates**: Gateway API saves to extensions_config.json; the Gateway-embedded runtime detects changes via the resolved-path + content-signature check above, so multi-worker / stale-mtime deployments still pick up an added/removed MCP server without a restart (the `PUT /api/mcp/config` reset only clears the cache in its own worker)
- **Runtime updates**: Gateway API saves to extensions_config.json; the Gateway-embedded runtime detects changes via the resolved-path + content-signature check above, so multi-worker / stale-mtime deployments still pick up an added/removed MCP server without a restart (`PUT /api/mcp/config` keeps whole-payload validation, while `PATCH /api/mcp/config` changes only one server's `enabled` field, normalizes the same `type`/MCP-spec `transport` alias as the runtime config model, and validates the target only when enabling it; either endpoint's reset clears the cache only in its own worker). MCP, skill, and embedded-client writers share `atomic_write_extensions_config()`, which writes and fsyncs a same-directory temporary file before `os.replace()` and preserves an existing file's mode and symlink target; failed serialization or replacement leaves the prior config intact and cleans up the temporary file.
### Skills System (`packages/harness/deerflow/skills/`)
@ -1219,7 +1219,7 @@ Config is env-driven like the others — `MonocleTracingConfig`, built in `get_t
- `skills` - Map of skill name → state (enabled)
- `middlewares` - Zero-argument `AgentMiddleware` class paths for lead and subagent runtime extension. `config.yaml -> extensions` can override these fields after validation; overrides are replace-per-field, not list concatenation.
Gateway API endpoints and `DeerFlowClient` methods can modify MCP servers and skill state at runtime; `middlewares` remains an operator-controlled config-file extension point.
Gateway API endpoints and `DeerFlowClient` methods can modify MCP servers and skill state at runtime; their `extensions_config.json` writes use the shared atomic replacement helper, while `middlewares` remains an operator-controlled config-file extension point.
### Embedded Client (`packages/harness/deerflow/client.py`)

View File

@ -7,10 +7,19 @@ from pathlib import Path
from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.gateway.deps import require_admin_user
from deerflow.config.extensions_config import ExtensionsConfig, McpRoutingConfig, McpToolOverride, extensions_config_write_lock, get_extensions_config, reload_extensions_config
from deerflow.config.extensions_config import (
ExtensionsConfig,
McpRoutingConfig,
McpToolOverride,
atomic_write_extensions_config,
extensions_config_write_lock,
get_extensions_config,
normalize_mcp_transport_alias,
reload_extensions_config,
)
from deerflow.mcp.cache import reset_mcp_tools_cache
logger = logging.getLogger(__name__)
@ -60,6 +69,12 @@ class McpServerConfigResponse(BaseModel):
tool_call_timeout: float | None = Field(default=None, description="Timeout in seconds for individual stdio MCP tool calls")
model_config = ConfigDict(extra="allow")
@model_validator(mode="before")
@classmethod
def _accept_transport_alias(cls, data: Any) -> Any:
"""Keep API parsing aligned with the runtime MCP config model."""
return normalize_mcp_transport_alias(data)
class McpConfigResponse(BaseModel):
"""Response model for MCP configuration."""
@ -79,6 +94,17 @@ class McpConfigUpdateRequest(BaseModel):
)
class McpServerStateUpdateRequest(BaseModel):
"""Request model for enabling or disabling one MCP server."""
server_name: str = Field(
...,
min_length=1,
description="Name of the MCP server to update",
)
enabled: bool = Field(..., description="Whether the MCP server is enabled")
class McpCacheResetResponse(BaseModel):
"""Response model for resetting the MCP tools cache."""
@ -383,9 +409,7 @@ def _apply_mcp_config_update(body: McpConfigUpdateRequest) -> dict:
config_data["mcpServers"] = {name: server.model_dump() for name, server in merged_servers.items()}
config_data["skills"] = {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()}
# Write the configuration to file
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config_data, f, indent=2)
atomic_write_extensions_config(config_path, config_data)
logger.info(f"MCP configuration updated and saved to: {config_path}")
@ -396,6 +420,43 @@ def _apply_mcp_config_update(body: McpConfigUpdateRequest) -> dict:
return reloaded_config.mcp_servers
def _apply_mcp_server_state_update(body: McpServerStateUpdateRequest) -> dict:
"""Update one server state while preserving the raw extensions config."""
with extensions_config_write_lock:
config_path = ExtensionsConfig.resolve_config_path()
if config_path is None or not config_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"MCP server '{body.server_name}' not found",
)
with open(config_path, encoding="utf-8") as f:
raw_data = json.load(f)
raw_servers = raw_data.get("mcpServers", {})
raw_server = raw_servers.get(body.server_name) if isinstance(raw_servers, dict) else None
if not isinstance(raw_server, dict):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"MCP server '{body.server_name}' not found",
)
if body.enabled:
target_server = McpServerConfigResponse(**raw_server)
_validate_mcp_update_request(
McpConfigUpdateRequest(
mcp_servers={body.server_name: target_server},
)
)
raw_server["enabled"] = body.enabled
atomic_write_extensions_config(config_path, raw_data)
logger.info("MCP server %s enabled state updated to %s", body.server_name, body.enabled)
reloaded_config = reload_extensions_config()
return reloaded_config.mcp_servers
@router.post(
"/mcp/cache/reset",
response_model=McpCacheResetResponse,
@ -475,3 +536,25 @@ async def update_mcp_configuration(request: Request, body: McpConfigUpdateReques
except Exception as e:
logger.error(f"Failed to update MCP configuration: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update MCP configuration: {str(e)}")
@router.patch(
"/mcp/config",
response_model=McpConfigResponse,
summary="Update MCP Server State",
description="Enable or disable one MCP server without replacing the full extensions configuration.",
)
async def update_mcp_server_state(request: Request, body: McpServerStateUpdateRequest) -> McpConfigResponse:
"""Enable or disable one MCP server and reload the MCP tool cache."""
try:
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
reloaded_servers = await asyncio.to_thread(_apply_mcp_server_state_update, body)
servers = {name: _mask_server_config(McpServerConfigResponse(**server.model_dump())) for name, server in reloaded_servers.items()}
reset_mcp_tools_cache()
return McpConfigResponse(mcp_servers=servers)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to update MCP server %s state: %s", body.server_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update MCP server state: {str(e)}")

View File

@ -1,5 +1,4 @@
import asyncio
import json
import logging
import tempfile
from pathlib import Path
@ -12,7 +11,14 @@ from app.gateway.deps import get_config, require_admin_user
from app.gateway.path_utils import resolve_thread_virtual_path
from deerflow.agents.lead_agent.prompt import clear_skills_system_prompt_cache, refresh_skills_system_prompt_cache_async, refresh_user_skills_system_prompt_cache_async
from deerflow.config.app_config import AppConfig
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, extensions_config_write_lock, get_extensions_config, reload_extensions_config
from deerflow.config.extensions_config import (
ExtensionsConfig,
SkillStateConfig,
atomic_write_extensions_config,
extensions_config_write_lock,
get_extensions_config,
reload_extensions_config,
)
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills import Skill
from deerflow.skills.installer import SkillAlreadyExistsError, SkillSecurityScanError
@ -445,8 +451,7 @@ def _write_extensions_skill_state(skill_name: str, enabled: bool) -> None:
config_data = extensions_config.to_file_dict()
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config_data, f, indent=2)
atomic_write_extensions_config(config_path, config_data)
logger.info(f"Skills configuration updated and saved to: {config_path}")
reload_extensions_config()

View File

@ -393,6 +393,36 @@ deployment needs additional trusted launchers.
}
```
#### Update One MCP Server State
Enable or disable one configured MCP server without replacing the full
extensions configuration.
```http
PATCH /api/mcp/config
Content-Type: application/json
```
Requires an authenticated admin session. Enabling a `stdio` server validates
that server's `command` against the same allowlist used by the full `PUT`
endpoint. Disabling a server does not require its command to be allowlisted, and
invalid commands on other servers do not block the update. The endpoint
preserves secrets, environment-variable placeholders, skills, custom server
fields, and other top-level extensions config. SSE/HTTP targets may use either
DeerFlow's `type` field or the MCP-spec `transport` field.
**Request Body:**
```json
{
"server_name": "semantic-scholar",
"enabled": false
}
```
The response is the full masked MCP configuration, matching `GET` and `PUT`.
An unknown `server_name` returns `404`; attempting to enable a server with a
disallowed `stdio` command returns `400`.
#### Reset MCP Tools Cache
Clear cached MCP tools and persistent MCP sessions process-wide. This affects

View File

@ -18,12 +18,10 @@ Usage:
import asyncio
import concurrent.futures
import copy
import json
import logging
import mimetypes
import os
import shutil
import tempfile
import uuid
from collections.abc import Generator, Mapping, Sequence
from dataclasses import dataclass, field
@ -41,7 +39,13 @@ from deerflow.agents.thread_state import get_thread_state_schema, normalize_midd
from deerflow.authz.principal import build_principal_from_context
from deerflow.config.agents_config import AGENT_NAME_PATTERN
from deerflow.config.app_config import get_app_config, is_trace_correlation_enabled, reload_app_config
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
from deerflow.config.extensions_config import (
ExtensionsConfig,
SkillStateConfig,
atomic_write_extensions_config,
get_extensions_config,
reload_extensions_config,
)
from deerflow.config.paths import get_paths
from deerflow.models import create_chat_model
from deerflow.runtime import CheckpointStateAccessor
@ -229,20 +233,7 @@ class DeerFlowClient:
@staticmethod
def _atomic_write_json(path: Path, data: dict) -> None:
"""Write JSON to *path* atomically (temp file + replace)."""
fd = tempfile.NamedTemporaryFile(
mode="w",
dir=path.parent,
suffix=".tmp",
delete=False,
)
try:
json.dump(data, fd, indent=2)
fd.close()
Path(fd.name).replace(path)
except BaseException:
fd.close()
Path(fd.name).unlink(missing_ok=True)
raise
atomic_write_extensions_config(path, data)
def _get_runnable_config(self, thread_id: str, **overrides) -> RunnableConfig:
"""Build a RunnableConfig for agent invocation."""

View File

@ -3,6 +3,8 @@
import json
import logging
import os
import stat
import tempfile
import threading
from pathlib import Path
from typing import Any, Literal
@ -14,6 +16,15 @@ from deerflow.config.runtime_paths import existing_project_file
logger = logging.getLogger(__name__)
def normalize_mcp_transport_alias(data: Any) -> Any:
"""Promote MCP-spec ``transport`` to ``type`` when ``type`` is absent."""
if isinstance(data, dict):
transport = data.get("transport")
if transport and not data.get("type"):
return {**data, "type": transport}
return data
class McpRoutingConfig(BaseModel):
"""Soft routing hints for MCP tool preference."""
@ -105,11 +116,7 @@ class McpServerConfig(BaseModel):
``stdio`` (the default). This validator normalizes the two so either
spelling works, with ``type`` taking precedence when both are provided.
"""
if isinstance(data, dict):
transport = data.get("transport")
if transport and not data.get("type"):
data = {**data, "type": transport}
return data
return normalize_mcp_transport_alias(data)
def resolve_effective_mcp_routing(server_config: McpServerConfig | None, original_tool_name: str) -> dict[str, Any]:
@ -323,6 +330,70 @@ class ExtensionsConfig(BaseModel):
_extensions_config: ExtensionsConfig | None = None
def _fsync_directory_best_effort(directory: Path) -> None:
"""Persist a directory entry update where the platform supports it."""
if os.name == "nt":
return
try:
directory_fd = os.open(directory, os.O_RDONLY)
except OSError:
return
try:
os.fsync(directory_fd)
except OSError:
logger.debug("Could not fsync extensions config directory: %s", directory, exc_info=True)
finally:
try:
os.close(directory_fd)
except OSError:
logger.debug("Could not close extensions config directory: %s", directory, exc_info=True)
def atomic_write_extensions_config(path: Path, data: dict[str, Any]) -> None:
"""Write extensions config without exposing a truncated or partial file."""
path = Path(path)
target_path = path.resolve(strict=False) if path.is_symlink() else path
target_path.parent.mkdir(parents=True, exist_ok=True)
existing_mode: int | None = None
try:
existing_mode = stat.S_IMODE(target_path.stat().st_mode)
except FileNotFoundError:
pass
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=target_path.parent,
prefix=f".{target_path.name}.",
suffix=".tmp",
delete=False,
) as temporary_file:
temporary_path = Path(temporary_file.name)
json.dump(data, temporary_file, indent=2)
if existing_mode is not None:
temporary_path.chmod(existing_mode)
temporary_file.flush()
os.fsync(temporary_file.fileno())
os.replace(temporary_path, target_path)
_fsync_directory_best_effort(target_path.parent)
finally:
if temporary_path is not None:
try:
temporary_path.unlink(missing_ok=True)
except OSError:
logger.warning(
"Could not remove temporary extensions config file: %s",
temporary_path,
exc_info=True,
)
def get_extensions_config() -> ExtensionsConfig:
"""Get the extensions config instance.

View File

@ -1,9 +1,9 @@
"""Regression anchor: updating MCP config must not block the event loop.
``update_mcp_configuration`` resolves the extensions config path, probes its
existence, reads the raw JSON, writes the merged config, and reloads it all
blocking filesystem IO. The handler offloads the whole read-modify-write via
``asyncio.to_thread``; if it regresses back onto the event loop, the strict
The PUT and PATCH handlers resolve the extensions config path, probe its
existence, read raw JSON, atomically write it, and reload it all blocking
filesystem IO. They offload the whole read-modify-write via
``asyncio.to_thread``; if either regresses back onto the event loop, the strict
Blockbuster gate raises ``BlockingError`` and this test fails.
The admin check is patched to a no-op so the anchor exercises the handler's own
@ -22,7 +22,13 @@ from types import SimpleNamespace
import pytest
from app.gateway.routers import mcp as mcp_router
from app.gateway.routers.mcp import McpConfigUpdateRequest, McpServerConfigResponse, update_mcp_configuration
from app.gateway.routers.mcp import (
McpConfigUpdateRequest,
McpServerConfigResponse,
McpServerStateUpdateRequest,
update_mcp_configuration,
update_mcp_server_state,
)
pytestmark = pytest.mark.asyncio
@ -52,15 +58,40 @@ async def test_update_mcp_configuration_does_not_block_event_loop(tmp_path: Path
assert await asyncio.to_thread(config_path.exists)
async def test_concurrent_mcp_updates_are_serialized(tmp_path: Path, monkeypatch) -> None:
async def test_update_mcp_server_state_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
config_path = tmp_path / "extensions_config.json"
await asyncio.to_thread(
config_path.write_text,
'{"mcpServers":{"remote":{"enabled":false,"transport":"http","url":"https://example.test/mcp"}},"skills":{}}',
encoding="utf-8",
)
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(config_path))
async def _noop_admin(_request, **_kwargs) -> None:
return None
monkeypatch.setattr(mcp_router, "require_admin_user", _noop_admin)
monkeypatch.setattr(mcp_router, "reset_mcp_tools_cache", lambda: None)
response = await update_mcp_server_state(
request=None,
body=McpServerStateUpdateRequest(server_name="remote", enabled=True),
)
assert response.mcp_servers["remote"].enabled is True
assert response.mcp_servers["remote"].type == "http"
async def test_concurrent_mcp_put_and_patch_updates_are_serialized(tmp_path: Path, monkeypatch) -> None:
"""The write lock keeps the offloaded read-modify-write atomic within the process.
Offloading the RMW to a worker thread dropped the implicit serialization the
single-threaded event loop provided. ``extensions_config_write_lock`` restores
it and, being shared with the skills router (the other writer of this file),
also serializes against skill toggles: even with several concurrent
``PUT /api/mcp/config`` calls, only one RMW is inside the critical section at a
time. (Without the lock the tracked max concurrency would exceed 1.)
mix of ``PUT /api/mcp/config`` and ``PATCH /api/mcp/config`` calls, only one
RMW is inside the critical section at a time. (Without the lock the tracked
max concurrency would exceed 1.)
The tracker is injected *inside* the real worker (via ``reload_extensions_config``,
the last step under the lock) rather than replacing ``_apply_mcp_config_update``,
@ -68,7 +99,11 @@ async def test_concurrent_mcp_updates_are_serialized(tmp_path: Path, monkeypatch
the very thing under test.
"""
config_path = tmp_path / "extensions_config.json"
await asyncio.to_thread(config_path.write_text, '{"mcpServers": {}, "skills": {}}', encoding="utf-8")
await asyncio.to_thread(
config_path.write_text,
'{"mcpServers":{"s":{"enabled":true,"type":"http","url":"https://example.test/mcp"}},"skills":{}}',
encoding="utf-8",
)
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(config_path))
async def _noop_admin(_request, **_kwargs) -> None:
@ -96,6 +131,12 @@ async def test_concurrent_mcp_updates_are_serialized(tmp_path: Path, monkeypatch
mcp_servers={"s": McpServerConfigResponse(type="http", url="https://example.test/mcp")},
)
await asyncio.gather(*[update_mcp_configuration(request=None, body=body) for _ in range(5)])
await asyncio.gather(
*[update_mcp_configuration(request=None, body=body) for _ in range(4)],
update_mcp_server_state(
request=None,
body=McpServerStateUpdateRequest(server_name="s", enabled=False),
),
)
assert counters["max"] == 1, f"config updates were not serialized (max concurrency {counters['max']})"

View File

@ -20,8 +20,8 @@ performs the same RMW on the same file.
go back to separate module-local locks.
Only the config-infra boundaries (storage / ``get_extensions_config`` / reload /
path resolution) are stubbed; the real ``open(config_path, "w")`` write to a tmp
file is exercised.
path resolution) are stubbed; the real same-directory temporary write and atomic
replacement are exercised.
"""
from __future__ import annotations
@ -141,7 +141,7 @@ async def test_update_skill_serializes_concurrent_writes(tmp_path: Path, monkeyp
update_skill("demo-skill", SkillUpdateRequest(enabled=True), _admin_request(), SimpleNamespace()),
)
# The shared asyncio.Lock must serialize the offloaded read-modify-write.
# The shared threading.Lock must serialize the offloaded read-modify-write.
assert counters["max"] == 1

View File

@ -0,0 +1,144 @@
"""Regression tests for crash-safe extensions config writes."""
from __future__ import annotations
import json
import os
import stat
from pathlib import Path
import pytest
from deerflow.config import extensions_config as extensions_config_module
from deerflow.config.extensions_config import atomic_write_extensions_config
def _temporary_files_for(path: Path) -> list[Path]:
return list(path.parent.glob(f".{path.name}.*.tmp"))
def test_atomic_write_replaces_config_without_leaving_temp_files(tmp_path: Path) -> None:
config_path = tmp_path / "extensions_config.json"
config_path.write_text('{"old": true}', encoding="utf-8")
atomic_write_extensions_config(
config_path,
{
"mcpServers": {"github": {"enabled": False}},
"skills": {"research": {"enabled": True}},
},
)
assert json.loads(config_path.read_text(encoding="utf-8")) == {
"mcpServers": {"github": {"enabled": False}},
"skills": {"research": {"enabled": True}},
}
assert _temporary_files_for(config_path) == []
def test_atomic_write_preserves_original_when_json_dump_fails_mid_write(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config_path = tmp_path / "extensions_config.json"
original = '{"mcpServers": {"github": {"enabled": true}}, "skills": {}}'
config_path.write_text(original, encoding="utf-8")
def fail_after_partial_write(_data, file_handle, **_kwargs) -> None:
file_handle.write('{"mcpServers":')
file_handle.flush()
raise OSError("disk full")
monkeypatch.setattr(extensions_config_module.json, "dump", fail_after_partial_write)
with pytest.raises(OSError, match="disk full"):
atomic_write_extensions_config(
config_path,
{"mcpServers": {"github": {"enabled": False}}, "skills": {}},
)
assert config_path.read_text(encoding="utf-8") == original
assert _temporary_files_for(config_path) == []
def test_atomic_write_preserves_original_when_replace_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config_path = tmp_path / "extensions_config.json"
original = '{"mcpServers": {}, "skills": {}}'
config_path.write_text(original, encoding="utf-8")
def fail_replace(_source, _destination) -> None:
raise OSError("replace failed")
monkeypatch.setattr(extensions_config_module.os, "replace", fail_replace)
with pytest.raises(OSError, match="replace failed"):
atomic_write_extensions_config(
config_path,
{"mcpServers": {"github": {"enabled": True}}, "skills": {}},
)
assert config_path.read_text(encoding="utf-8") == original
assert _temporary_files_for(config_path) == []
def test_atomic_write_preserves_original_when_file_fsync_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config_path = tmp_path / "extensions_config.json"
original = '{"mcpServers": {}, "skills": {}}'
config_path.write_text(original, encoding="utf-8")
def fail_fsync(_file_descriptor) -> None:
raise OSError("fsync failed")
monkeypatch.setattr(extensions_config_module.os, "fsync", fail_fsync)
with pytest.raises(OSError, match="fsync failed"):
atomic_write_extensions_config(
config_path,
{"mcpServers": {"github": {"enabled": True}}, "skills": {}},
)
assert config_path.read_text(encoding="utf-8") == original
assert _temporary_files_for(config_path) == []
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits unavailable")
def test_atomic_write_preserves_existing_file_mode(tmp_path: Path) -> None:
config_path = tmp_path / "extensions_config.json"
config_path.write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8")
config_path.chmod(0o640)
atomic_write_extensions_config(
config_path,
{"mcpServers": {}, "skills": {"research": {"enabled": False}}},
)
assert stat.S_IMODE(config_path.stat().st_mode) == 0o640
def test_atomic_write_updates_symlink_target_without_replacing_symlink(
tmp_path: Path,
) -> None:
target_path = tmp_path / "actual-extensions-config.json"
target_path.write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8")
config_path = tmp_path / "extensions_config.json"
try:
config_path.symlink_to(target_path)
except OSError as error:
pytest.skip(f"Symlinks are unavailable: {error}")
atomic_write_extensions_config(
config_path,
{"mcpServers": {"github": {"enabled": False}}, "skills": {}},
)
assert config_path.is_symlink()
assert json.loads(target_path.read_text(encoding="utf-8")) == {
"mcpServers": {"github": {"enabled": False}},
"skills": {},
}

View File

@ -3,6 +3,8 @@
Verifies that GET /api/mcp/config masks sensitive fields (env values,
header values, OAuth secrets) and that PUT /api/mcp/config correctly
preserves existing secrets when the frontend round-trips masked values.
PATCH /api/mcp/config coverage pins targeted state changes, raw-config
preservation, transport aliases, authorization, and command validation.
"""
from __future__ import annotations
@ -21,13 +23,15 @@ from app.gateway.routers.mcp import (
McpConfigUpdateRequest,
McpOAuthConfigResponse,
McpServerConfigResponse,
McpServerStateUpdateRequest,
_mask_server_config,
_merge_preserving_secrets,
_validate_mcp_update_request,
reset_mcp_tools_cache_endpoint,
update_mcp_configuration,
update_mcp_server_state,
)
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig
# ---------------------------------------------------------------------------
# _mask_server_config
@ -589,6 +593,202 @@ async def test_update_mcp_configuration_preserves_server_extra_fields(monkeypatc
assert response.mcp_servers["playwright"].model_extra["api_key"] == "***"
@pytest.mark.asyncio
@pytest.mark.parametrize("enabled", [False, True])
async def test_update_mcp_server_state_updates_valid_target_despite_unrelated_disallowed_command(
monkeypatch,
tmp_path,
enabled: bool,
):
config_path = tmp_path / "extensions_config.json"
original = {
"mcpServers": {
"semantic-scholar": {
"enabled": True,
"type": "stdio",
"command": "s2-mcp-server",
"env": {"S2_API_KEY": "$S2_API_KEY"},
"customFlag": "keep-me",
},
"github": {
"enabled": not enabled,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
},
},
"skills": {"research": {"enabled": False}},
"middlewares": ["example.middleware:Middleware"],
"customTopLevel": {"preserve": True},
}
config_path.write_text(json.dumps(original), encoding="utf-8")
reset_calls = 0
def fake_reload_extensions_config():
return ExtensionsConfig.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
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, "reload_extensions_config", fake_reload_extensions_config)
monkeypatch.setattr(mcp_router, "reset_mcp_tools_cache", fake_reset_mcp_tools_cache)
monkeypatch.delenv(_MCP_STDIO_COMMAND_ALLOWLIST_ENV, raising=False)
response = await update_mcp_server_state(
_request_with_role("admin"),
McpServerStateUpdateRequest(server_name="github", enabled=enabled),
)
persisted = json.loads(config_path.read_text(encoding="utf-8"))
assert persisted["mcpServers"]["github"]["enabled"] is enabled
assert persisted["mcpServers"]["semantic-scholar"] == original["mcpServers"]["semantic-scholar"]
assert persisted["skills"] == original["skills"]
assert persisted["middlewares"] == original["middlewares"]
assert persisted["customTopLevel"] == original["customTopLevel"]
assert response.mcp_servers["github"].enabled is enabled
assert response.mcp_servers["semantic-scholar"].env == {"S2_API_KEY": "***"}
assert reset_calls == 1
@pytest.mark.asyncio
async def test_update_mcp_server_state_allows_disabling_but_rejects_enabling_disallowed_command(monkeypatch, tmp_path):
config_path = tmp_path / "extensions_config.json"
config_path.write_text(
json.dumps(
{
"mcpServers": {
"semantic-scholar": {
"enabled": True,
"type": "stdio",
"command": "s2-mcp-server",
}
},
"skills": {},
}
),
encoding="utf-8",
)
reset_calls = 0
def fake_reload_extensions_config():
return ExtensionsConfig.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
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, "reload_extensions_config", fake_reload_extensions_config)
monkeypatch.setattr(mcp_router, "reset_mcp_tools_cache", fake_reset_mcp_tools_cache)
monkeypatch.delenv(_MCP_STDIO_COMMAND_ALLOWLIST_ENV, raising=False)
response = await update_mcp_server_state(
_request_with_role("admin"),
McpServerStateUpdateRequest(server_name="semantic-scholar", enabled=False),
)
assert response.mcp_servers["semantic-scholar"].enabled is False
with pytest.raises(HTTPException) as exc_info:
await update_mcp_server_state(
_request_with_role("admin"),
McpServerStateUpdateRequest(server_name="semantic-scholar", enabled=True),
)
assert exc_info.value.status_code == 400
assert "s2-mcp-server" in exc_info.value.detail
persisted = json.loads(config_path.read_text(encoding="utf-8"))
assert persisted["mcpServers"]["semantic-scholar"]["enabled"] is False
assert reset_calls == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("transport", ["sse", "http"])
async def test_update_mcp_server_state_enables_raw_transport_alias(
monkeypatch,
tmp_path,
transport: str,
):
config_path = tmp_path / "extensions_config.json"
original_server = {
"enabled": False,
"transport": transport,
"url": "https://mcp.example.com/mcp",
"customFlag": "keep-me",
}
config_path.write_text(
json.dumps(
{
"mcpServers": {"remote": original_server},
"skills": {},
}
),
encoding="utf-8",
)
reset_calls = 0
def fake_reload_extensions_config():
return ExtensionsConfig.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
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, "reload_extensions_config", fake_reload_extensions_config)
monkeypatch.setattr(mcp_router, "reset_mcp_tools_cache", fake_reset_mcp_tools_cache)
monkeypatch.delenv(_MCP_STDIO_COMMAND_ALLOWLIST_ENV, raising=False)
response = await update_mcp_server_state(
_request_with_role("admin"),
McpServerStateUpdateRequest(server_name="remote", enabled=True),
)
persisted_server = json.loads(config_path.read_text(encoding="utf-8"))["mcpServers"]["remote"]
assert persisted_server == {**original_server, "enabled": True}
assert "type" not in persisted_server
assert response.mcp_servers["remote"].enabled is True
assert response.mcp_servers["remote"].type == transport
assert reset_calls == 1
@pytest.mark.asyncio
async def test_update_mcp_server_state_returns_404_without_writing_or_resetting_cache(monkeypatch, tmp_path):
config_path = tmp_path / "extensions_config.json"
original_text = '{"mcpServers": {}, "skills": {}}'
config_path.write_text(original_text, encoding="utf-8")
reset_calls = 0
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, "reset_mcp_tools_cache", fake_reset_mcp_tools_cache)
with pytest.raises(HTTPException) as exc_info:
await update_mcp_server_state(
_request_with_role("admin"),
McpServerStateUpdateRequest(server_name="missing", enabled=True),
)
assert exc_info.value.status_code == 404
assert config_path.read_text(encoding="utf-8") == original_text
assert reset_calls == 0
@pytest.mark.asyncio
async def test_update_mcp_server_state_requires_admin():
with pytest.raises(HTTPException) as exc_info:
await update_mcp_server_state(
_request_with_role("user"),
McpServerStateUpdateRequest(server_name="github", enabled=False),
)
assert exc_info.value.status_code == 403
def test_validate_mcp_update_allows_default_npx_stdio_command(monkeypatch):
monkeypatch.delenv(_MCP_STDIO_COMMAND_ALLOWLIST_ENV, raising=False)
request = McpConfigUpdateRequest(
@ -689,3 +889,48 @@ def test_validate_mcp_update_ignores_remote_transports(monkeypatch):
)
_validate_mcp_update_request(request)
@pytest.mark.parametrize(
("raw_server", "expected_type"),
[
({"transport": "sse", "url": "https://mcp.example.com/sse"}, "sse"),
({"transport": "http", "url": "https://mcp.example.com/mcp"}, "http"),
({"transport": "stdio", "command": "npx"}, "stdio"),
({"type": "http", "transport": "sse", "url": "https://mcp.example.com/mcp"}, "http"),
({}, "stdio"),
],
)
def test_api_and_runtime_mcp_models_normalize_transport_consistently(
raw_server: dict[str, object],
expected_type: str,
):
api_server = McpServerConfigResponse.model_validate(raw_server)
runtime_server = McpServerConfig.model_validate(raw_server)
assert api_server.type == expected_type
assert runtime_server.type == expected_type
assert api_server.type == runtime_server.type
if "transport" in raw_server:
assert api_server.model_extra["transport"] == raw_server["transport"]
assert runtime_server.model_extra["transport"] == raw_server["transport"]
def test_validate_mcp_update_enforces_stdio_transport_alias(monkeypatch):
monkeypatch.delenv(_MCP_STDIO_COMMAND_ALLOWLIST_ENV, raising=False)
request = McpConfigUpdateRequest.model_validate(
{
"mcp_servers": {
"disallowed": {
"transport": "stdio",
"command": "custom-mcp-server",
}
}
}
)
with pytest.raises(HTTPException) as exc_info:
_validate_mcp_update_request(request)
assert exc_info.value.status_code == 400
assert "custom-mcp-server" in exc_info.value.detail

View File

@ -72,7 +72,11 @@ The frontend is a stateful chat application. Users create **threads** (conversat
Formal artifact content is refreshed once when the run finishes; transient `write-file:` previews remain message-driven.
3. `useThreadHistory` loads persisted conversation pages from `GET /api/threads/{id}/messages/page`, preserving the backend's thread-global event `seq`; rendering overlays checkpoint/live copies at their matching canonical identities (a summarized checkpoint may contain a protected early input plus a recent tail). Context-compaction rescue diffs every retained visible identity rather than slicing at the first anchor, and keeps a run-scoped ledger of committed visible messages so replacement updates and repeated rolling checkpoint windows cannot erase an already displayed step. The resolver suppresses checkpoint/transient prefixes whose canonical position is still behind an unloaded cursor page instead of collapsing that unknown gap before a recent anchor, then adds optimistic messages without timestamp re-sorting. History invalidation preserves already-loaded pages so their established ordering positions are not discarded.
4. Stop actions call the LangGraph SDK stream stop path; `core/threads/hooks.ts` invalidates current-thread, thread-history, token-usage, and sidebar/search caches immediately and schedules one follow-up refetch because SDK stop may finish via abort + fire-and-forget cancel before backend title finalization commits
5. TanStack Query manages server state; localStorage stores user settings
5. TanStack Query manages server state; localStorage stores user settings. The
Settings > Tools MCP switch calls the targeted `PATCH /api/mcp/config`
mutation, disables switches until that mutation's success refetch completes,
displays the backend error `detail` through a toast, and invalidates
`["mcpConfig"]` only after success.
6. Components subscribe to thread state and render updates
Run duration is run-scoped UI metadata even though the compatibility field `additional_kwargs.turn_duration` is repeated on historical AI messages. `core/messages/run-duration.ts` folds those copies into one display anchored after the run's last visible message group. `MessageList` owns the temporary client-side duration for a just-completed live turn until authoritative history arrives. The duration is total run wall-clock time, not per-message reasoning time; reasoning disclosure and run activity/duration are rendered separately.

View File

@ -47,7 +47,7 @@ function MCPServerList({
servers?: Record<string, MCPServerConfig>;
}) {
const { t } = useI18n();
const { mutate: enableMCPServer } = useEnableMCPServer();
const { isPending, mutate: enableMCPServer } = useEnableMCPServer();
const entries = Object.entries(servers ?? {});
if (entries.length === 0) {
return (
@ -73,7 +73,9 @@ function MCPServerList({
<ItemActions>
<Switch
checked={config.enabled}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isPending
}
onCheckedChange={(checked) =>
enableMCPServer({ serverName: name, enabled: checked })
}

View File

@ -52,3 +52,26 @@ export async function updateMCPConfig(config: MCPConfig) {
}
return response.json();
}
export async function updateMCPServerState(
serverName: string,
enabled: boolean,
) {
const response = await fetch(`${getBackendBaseURL()}/api/mcp/config`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
server_name: serverName,
enabled,
}),
});
if (!response.ok) {
throw new MCPConfigRequestError(
response.status,
await readErrorDetail(response, "Failed to update MCP server"),
);
}
return response.json() as Promise<MCPConfig>;
}

View File

@ -1,6 +1,16 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
type QueryClient,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { loadMCPConfig, MCPConfigRequestError, updateMCPConfig } from "./api";
import {
loadMCPConfig,
MCPConfigRequestError,
updateMCPServerState,
} from "./api";
export function useMCPConfig() {
const { data, isLoading, error } = useQuery({
@ -12,35 +22,23 @@ export function useMCPConfig() {
return { config: data, isLoading, error };
}
interface EnableMCPServerVariables {
serverName: string;
enabled: boolean;
}
export function getEnableMCPServerMutationOptions(queryClient: QueryClient) {
return {
mutationFn: ({ serverName, enabled }: EnableMCPServerVariables) =>
updateMCPServerState(serverName, enabled),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["mcpConfig"] }),
onError: (error: Error) => {
toast.error(error.message);
},
};
}
export function useEnableMCPServer() {
const queryClient = useQueryClient();
const { config } = useMCPConfig();
return useMutation({
mutationFn: async ({
serverName,
enabled,
}: {
serverName: string;
enabled: boolean;
}) => {
if (!config) {
throw new Error("MCP config not found");
}
if (!config.mcp_servers[serverName]) {
throw new Error(`MCP server ${serverName} not found`);
}
await updateMCPConfig({
mcp_servers: {
...config.mcp_servers,
[serverName]: {
...config.mcp_servers[serverName],
enabled,
},
},
});
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["mcpConfig"] });
},
});
return useMutation(getEnableMCPServerMutationOptions(queryClient));
}

View File

@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
const mcpMockState = rs.hoisted(() => ({
isPending: false,
mutate: rs.fn(),
}));
rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({
t: {
common: { loading: "Loading" },
settings: {
tools: {
title: "Tools",
description: "Manage MCP tools",
adminRequired: "Admin required",
empty: "No tools",
},
},
},
}),
}));
rs.mock("@/core/mcp/hooks", () => ({
useMCPConfig: () => ({
config: {
mcp_servers: {
github: { enabled: true, description: "GitHub tools" },
remote: { enabled: false, description: "Remote tools" },
},
},
isLoading: false,
error: null,
}),
useEnableMCPServer: () => ({
isPending: mcpMockState.isPending,
mutate: mcpMockState.mutate,
}),
}));
rs.mock("@/env", () => ({
env: { NEXT_PUBLIC_STATIC_WEBSITE_ONLY: "false" },
}));
afterEach(() => {
mcpMockState.isPending = false;
mcpMockState.mutate.mockReset();
cleanup();
});
describe("ToolSettingsPage MCP switches", () => {
it("disables every switch while a targeted update is pending", () => {
mcpMockState.isPending = true;
render(<ToolSettingsPage />);
const switches = screen.getAllByRole("switch");
expect(switches).toHaveLength(2);
for (const item of switches) {
expect((item as HTMLButtonElement).disabled).toBe(true);
}
});
it("submits only the selected server state when idle", () => {
render(<ToolSettingsPage />);
const switches = screen.getAllByRole("switch");
const githubSwitch = switches[0];
expect(githubSwitch).toBeDefined();
expect((githubSwitch as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(githubSwitch!);
expect(mcpMockState.mutate).toHaveBeenCalledWith({
serverName: "github",
enabled: false,
});
});
});

View File

@ -28,6 +28,7 @@ import {
MCPConfigRequestError,
loadMCPConfig,
updateMCPConfig,
updateMCPServerState,
} from "@/core/mcp/api";
const mockedFetch = rs.mocked(fetcher);
@ -116,3 +117,41 @@ describe("updateMCPConfig", () => {
});
});
});
describe("updateMCPServerState", () => {
test("patches only the requested server state", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
mcp_servers: { github: { enabled: false } },
}),
);
await expect(updateMCPServerState("github", false)).resolves.toEqual({
mcp_servers: { github: { enabled: false } },
});
expect(mockedFetch).toHaveBeenCalledWith("/api/mcp/config", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
server_name: "github",
enabled: false,
}),
});
});
test("surfaces backend validation detail", async () => {
const detail =
"MCP server 'semantic-scholar' uses disallowed stdio command 's2-mcp-server'.";
mockedFetch.mockResolvedValueOnce(jsonResponse(400, { detail }));
await expect(
updateMCPServerState("semantic-scholar", true),
).rejects.toMatchObject({
name: "MCPConfigRequestError",
status: 400,
message: detail,
});
});
});

View File

@ -1,14 +1,23 @@
import { beforeEach, describe, expect, it, rs } from "@rstest/core";
import { QueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
rs.mock("@/core/api/fetcher", () => ({
fetch: rs.fn(),
}));
rs.mock("sonner", () => ({
toast: {
error: rs.fn(),
},
}));
import { fetch } from "@/core/api/fetcher";
import { MCPConfigRequestError, loadMCPConfig } from "@/core/mcp/api";
import { getEnableMCPServerMutationOptions } from "@/core/mcp/hooks";
const mockedFetch = rs.mocked(fetch);
const mockedToastError = rs.mocked(toast.error);
function makeClient() {
return new QueryClient({
@ -23,6 +32,7 @@ function makeClient() {
describe("useMCPConfig retry policy", () => {
beforeEach(() => {
mockedFetch.mockReset();
mockedToastError.mockReset();
});
it("does not retry when loadMCPConfig throws MCPConfigRequestError (403)", async () => {
@ -82,3 +92,50 @@ describe("useMCPConfig retry policy", () => {
expect(mockedFetch).toHaveBeenCalledTimes(1);
});
});
describe("MCP server state mutation", () => {
beforeEach(() => {
mockedFetch.mockReset();
mockedToastError.mockReset();
});
it("invalidates MCP config after a successful targeted update", async () => {
mockedFetch.mockResolvedValue(
new Response(JSON.stringify({ mcp_servers: {} }), { status: 200 }),
);
const client = makeClient();
const invalidateQueries = rs
.spyOn(client, "invalidateQueries")
.mockResolvedValue();
const mutation = client
.getMutationCache()
.build(client, getEnableMCPServerMutationOptions(client));
await mutation.execute({ serverName: "github", enabled: false });
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ["mcpConfig"],
});
expect(mockedToastError).not.toHaveBeenCalled();
});
it("shows the backend error detail when a targeted update fails", async () => {
const detail =
"MCP server 'semantic-scholar' uses disallowed stdio command 's2-mcp-server'.";
mockedFetch.mockResolvedValue(
new Response(JSON.stringify({ detail }), { status: 400 }),
);
const client = makeClient();
const invalidateQueries = rs.spyOn(client, "invalidateQueries");
const mutation = client
.getMutationCache()
.build(client, getEnableMCPServerMutationOptions(client));
await expect(
mutation.execute({ serverName: "semantic-scholar", enabled: true }),
).rejects.toThrow(detail);
expect(mockedToastError).toHaveBeenCalledWith(detail);
expect(invalidateQueries).not.toHaveBeenCalled();
});
});