fix(client): honor agent MCP plugin selections (#5630)

* fix(client): honor named-agent MCP plugin selections

* fix(client): normalize MCP selection cache identity
This commit is contained in:
Weng Qiang 2026-09-21 21:45:52 +08:00 committed by GitHub
parent 29d285731b
commit 60d5659d1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 111 additions and 7 deletions

View File

@ -1848,6 +1848,12 @@ DeerFlow is model-agnostic — it works with any LLM that implements the OpenAI-
## Embedded Python Client
For `DeerFlowClient(agent_name="researcher")`, the named agent's `mcp_plugins`
selection applies to both the lead agent and its `task` / `batch_task`
delegations: `null` inherits all enabled MCP plugins, `[]` selects none, and
installation IDs select only those plugins. Call `client.reset_agent()` after
editing the saved agent configuration to refresh the selection.
`DeerFlowClient.stream()` includes `summary_text` in each `values` event. This is the current compacted context summary, or `None` when absent. Consumers can record changes without reading checkpoint internals; repeated snapshots may carry the same summary, and an initial snapshot may already contain one from an earlier turn.
DeerFlow can be used as an embedded Python library without running the full HTTP services. The `DeerFlowClient` provides direct in-process access to all agent and Gateway capabilities, returning the same response schemas as the HTTP Gateway API. The HTTP Gateway also exposes `DELETE /api/threads/{thread_id}` to remove DeerFlow-managed local thread data after the LangGraph thread itself has been deleted:

View File

@ -62,10 +62,10 @@ drift.
- `"custom"` — forwarded from `StreamWriter`; DeerFlow-built-in custom events are dual-emitted through `deerflow.utils.custom_events`, so `astream_events(version="v2")` consumers also receive one `on_custom_event` with `name=payload["type"]` and the unchanged payload as `data`
- `"end"` — stream finished (carries cumulative `usage` counted once per message id)
- **Custom-event invariant** — use `emit_custom_event` / `aemit_custom_event`, never `StreamWriter` alone. Built-in payloads require a non-empty string `type`; typeless payloads stay writer-only, absent from `astream_events`. The writer runs first and is authoritative for Gateway/Web UI/embedded clients; best-effort callbacks must not break it. Async graph hooks must await the async helper, never dispatch synchronously on a running event loop.
- Agent created lazily via `create_agent()` + `build_middlewares()`, same as `make_lead_agent`
- Cache graphs by effective storage `user_id` in every auth mode because prompts and middleware bind user SOUL, skills, and storage. `stream()` must materialize it before worker or isolated-loop boundaries.
- Lazy graph creation uses `create_agent()` + `build_middlewares()`.
- Cache graphs by storage `user_id` and the unordered set of named-agent `mcp_plugins`. `stream()` materializes `user_id` before worker/loop boundaries in every auth mode.
- Supports `checkpointer` parameter for state persistence across turns
- `reset_agent()` forces agent recreation (e.g. after memory or skill changes)
- `reset_agent()` reloads AgentConfig and rebuilds the graph. Every run's metadata carries `mcp_plugins` for delegation, including cache hits.
- [Streaming design](../../../docs/STREAMING.md): Gateway/client parallel paths, LangGraph `stream_mode`, per-id deduplication, and regression tests
**Gateway Equivalent Methods** (replaces Gateway API):

View File

@ -324,6 +324,9 @@ class DeerFlowClient:
self._loaded_agent_config_key = loaded_config_key
self._loaded_agent_config = agent_config
memory_enabled = getattr(agent_config, "memory_enabled", True) is not False
mcp_plugins = getattr(agent_config, "mcp_plugins", None)
# Delegation reads this run's metadata, including when the graph is cached.
config.setdefault("metadata", {})["mcp_plugins"] = mcp_plugins
authorization_identity = None
if self._app_config.authorization.enabled:
@ -349,6 +352,7 @@ class DeerFlowClient:
cfg.get("max_total_subagents"),
self._agent_name,
memory_enabled,
frozenset(mcp_plugins) if mcp_plugins is not None else None,
frozenset(self._available_skills) if self._available_skills is not None else None,
self._checkpoint_channel_mode,
self._checkpoint_snapshot_frequency,
@ -390,7 +394,7 @@ class DeerFlowClient:
)
max_total_subagents = cfg.get("max_total_subagents", self._app_config.subagents.max_total_per_run)
tools = self._get_tools(model_name=model_name, subagent_enabled=subagent_enabled)
tools = self._get_tools(model_name=model_name, subagent_enabled=subagent_enabled, mcp_plugins=mcp_plugins)
# Add framework-provided tools before authorization so Layer 1 sees
# every capability that can become model-visible.
@ -483,11 +487,11 @@ class DeerFlowClient:
logger.info("Agent created: agent_name=%s, model=%s, thinking=%s", self._agent_name, model_name, thinking_enabled)
@staticmethod
def _get_tools(*, model_name: str | None, subagent_enabled: bool):
def _get_tools(*, model_name: str | None, subagent_enabled: bool, mcp_plugins: list[str] | None = None):
"""Lazy import to avoid circular dependency at module level."""
from deerflow.tools import get_available_tools
return get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled)
return get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, mcp_plugins=mcp_plugins)
@staticmethod
def _serialize_tool_calls(tool_calls) -> list[dict]:

View File

@ -1277,6 +1277,99 @@ class TestExtractText:
# ---------------------------------------------------------------------------
class TestClientMcpSelection:
@pytest.fixture
def mcp_client(self, client):
from deerflow.config.app_config import AppConfig
from deerflow.config.sandbox_config import SandboxConfig
app_config = AppConfig(models=[], sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"))
app_config.tool_search.enabled = False
client._app_config = app_config
client._agent_name = "researcher"
extensions = ExtensionsConfig.model_validate({"mcpServers": {name: {"enabled": True, "capability": {"id": identity}} for name, identity in [("work", "installation-A"), ("personal", "installation-B")]}})
cached_tools = [tag_mcp_tool(StructuredTool.from_function(lambda: "result", name=f"{name}_search", description="Search"), server_name=name) for name in extensions.mcp_servers]
graph = MagicMock()
graph.stream.return_value = []
with (
patch("deerflow.client.create_chat_model"),
patch("deerflow.client.create_agent", return_value=graph) as create_agent,
patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value="prompt"),
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
patch("deerflow.client.load_agent_config") as load_config,
patch("deerflow.tools.tools.get_app_config", return_value=app_config),
patch("deerflow.config.acp_config.get_acp_agents", return_value={}),
patch.object(ExtensionsConfig, "from_file", return_value=extensions),
patch("deerflow.mcp.cache.get_cached_mcp_tools", return_value=cached_tools),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
):
yield SimpleNamespace(client=client, graph=graph, create_agent=create_agent, load_config=load_config, cached_tools=cached_tools)
@pytest.mark.parametrize(
("selection", "expected_names"),
[(None, ["work_search", "personal_search"]), ([], []), (["installation-A"], ["work_search"])],
)
def test_selects_mcp_tools_without_changing_shared_cache(self, mcp_client, selection, expected_names):
from deerflow.tools.mcp_metadata import is_mcp_tool
mcp_client.load_config.return_value = AgentConfig(name="researcher", mcp_plugins=selection)
config = mcp_client.client._get_runnable_config("t1")
mcp_client.client._ensure_agent(config)
tools = mcp_client.create_agent.call_args.kwargs["tools"]
assert [tool.name for tool in tools if is_mcp_tool(tool)] == expected_names
assert [tool.name for tool in mcp_client.cached_tools] == ["work_search", "personal_search"]
@pytest.mark.parametrize("selection", [None, [], ["installation-A"]])
def test_each_stream_carries_mcp_selection_for_delegation_on_cache_hit(self, mcp_client, selection):
mcp_client.load_config.return_value = AgentConfig(name="researcher", mcp_plugins=selection)
for _ in range(2):
list(mcp_client.client.stream("hello", thread_id="t1"))
mcp_client.create_agent.assert_called_once()
mcp_client.load_config.assert_called_once()
assert mcp_client.graph.stream.call_count == 2
for call in mcp_client.graph.stream.call_args_list:
metadata = call.kwargs["config"]["metadata"]
assert metadata["mcp_plugins"] == selection
def test_reuses_graph_when_mcp_selection_order_changes(self, mcp_client):
agent_config = AgentConfig(name="researcher", mcp_plugins=["installation-A", "installation-B"])
mcp_client.load_config.return_value = agent_config
client = mcp_client.client
client._ensure_agent(client._get_runnable_config("t1"))
agent_config.mcp_plugins = ["installation-B", "installation-A"]
config = client._get_runnable_config("t2")
client._ensure_agent(config)
mcp_client.create_agent.assert_called_once()
assert config["metadata"]["mcp_plugins"] == ["installation-B", "installation-A"]
def test_reset_refreshes_mcp_selection_and_graph_cache_identity(self, mcp_client):
client = mcp_client.client
keys = []
for selection in [None, [], ["installation-A"]]:
mcp_client.load_config.return_value = AgentConfig(name="researcher", mcp_plugins=selection)
client.reset_agent()
config = client._get_runnable_config("t1")
config["metadata"] = {"existing": "preserved", "mcp_plugins": ["installation-B"]}
client._ensure_agent(config)
keys.append(client._agent_config_key)
assert config["metadata"] == {"existing": "preserved", "mcp_plugins": selection}
# Changing the saved config takes effect only after reset_agent().
mcp_client.load_config.return_value = AgentConfig(name="researcher", mcp_plugins=["installation-B"])
cached_config = client._get_runnable_config("t2")
client._ensure_agent(cached_config)
assert cached_config["metadata"]["mcp_plugins"] == selection
assert keys[0] != keys[1] != keys[2]
assert mcp_client.load_config.call_count == 3
assert mcp_client.create_agent.call_count == 3
class TestEnsureAgent:
@pytest.mark.parametrize(
("agent_name", "agent_config", "expected_memory_enabled"),
@ -1691,6 +1784,7 @@ class TestEnsureAgent:
None,
True,
None,
None,
"full",
10,
get_effective_user_id(),

View File

@ -679,7 +679,7 @@ def _stub_client_assembly(monkeypatch) -> dict[str, str]:
)
monkeypatch.setattr("deerflow.client.create_agent", lambda **kwargs: object())
monkeypatch.setattr("deerflow.client.build_middlewares", lambda *args, **kwargs: [])
monkeypatch.setattr("deerflow.client.DeerFlowClient._get_tools", staticmethod(lambda *, model_name, subagent_enabled: [])) # noqa: ARG005
monkeypatch.setattr("deerflow.client.DeerFlowClient._get_tools", staticmethod(lambda *, model_name, subagent_enabled, mcp_plugins=None: [])) # noqa: ARG005
monkeypatch.setattr("deerflow.client.get_enabled_skills_for_config", lambda app_config: []) # noqa: ARG005
monkeypatch.setattr(
"deerflow.client.build_skill_search_setup",