From a263af284527749b714535f1776f0247966ec8bb Mon Sep 17 00:00:00 2001 From: Hao Zhe <130889443+ehz0ah@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:56:02 +0800 Subject: [PATCH] feat(mcp): add official OpenViking tools integration (#4745) * feat(mcp): add OpenViking tools integration * fix(mcp): warn on ineffective tool overrides * docs(mcp): clarify OpenViking resource removal * fix(mcp): expose native OpenViking forget tool * docs(mcp): document OpenViking forget guardrail --- CHANGELOG.md | 3 + README.md | 8 + backend/docs/MCP_SERVER.md | 66 +++++++ .../tests/test_openviking_mcp_integration.py | 172 ++++++++++++++++++ extensions_config.example.json | 9 + 5 files changed, 258 insertions(+) create mode 100644 backend/tests/test_openviking_mcp_integration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index df37dc9c0..a378e07cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,6 +157,8 @@ This section accumulates work toward the **2.1.0** milestone [#3866]) - **mcp:** Per-server `tool_call_timeout` for MCP tool calls, and routing hints that guide the model to the right server. ([#3843], [#4004]) +- **mcp:** Add an official OpenViking `/mcp` example that exposes the native + tool set through DeerFlow's generic MCP client. ([#4745]) - **community:** Agentic browser control as a first-class thread capability - Playwright-backed browser sessions the agent operates while the user observes or takes over from the workspace. ([#4187]) @@ -1417,3 +1419,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#4471]: https://github.com/bytedance/deer-flow/pull/4471 [#4516]: https://github.com/bytedance/deer-flow/pull/4516 [#4611]: https://github.com/bytedance/deer-flow/issues/4611 +[#4745]: https://github.com/bytedance/deer-flow/pull/4745 diff --git a/README.md b/README.md index 107041342..d02c1d9c5 100644 --- a/README.md +++ b/README.md @@ -417,6 +417,14 @@ Targeted updates accept both DeerFlow's `type` field and the MCP-spec `transport 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. +OpenViking users can register the official Streamable HTTP endpoint at `/mcp` +with an owner-bound USER API key. The native `forget` tool is exposed for +capability parity; deletion is irreversible, so it should be called only after +explicit user confirmation. DeerFlow does not enforce that confirmation. This +explicit, model-selected MCP tool path can run alongside the separate automatic +OpenViking memory backend; it does not replace automatic turn capture or recall. See the +[OpenViking MCP tools configuration](backend/docs/MCP_SERVER.md#openviking-mcp-tools). + The Gateway also includes a disabled-by-default, protocol-neutral foundation for durable long-running MCP tasks. It stores remote task handles outside model context, polls them under cross-worker leases, rejects results returned after their lease expires, schedules the next attempt from the time a remote status call finishes, isolates unexpected failures between claimed tasks, cancels in-flight polling during Gateway shutdown, and makes expired claims recoverable after restart. If remote submission succeeds but the handle cannot be persisted, the runtime makes a best-effort cancellation so an untracked task is not silently left running. The exact scoped duplicate-handle conflict is surfaced without cancellation because an existing durable row already owns that remote task. Durable recovery requires a SQL database backend (`sqlite` or `postgres`); the in-memory backend does not initialize this task repository. This foundation does not make existing MCP tools asynchronous by itself: `mcp_tasks.enabled` should remain `false` until a compatible task driver is configured. Ordinary `submit/status/cancel` tools and the future MCP Tasks extension can share the same runtime without making the model remember remote task IDs. See the [MCP Server Guide](backend/docs/MCP_SERVER.md) for detailed instructions. diff --git a/backend/docs/MCP_SERVER.md b/backend/docs/MCP_SERVER.md index a8f34ca03..fb47a6503 100644 --- a/backend/docs/MCP_SERVER.md +++ b/backend/docs/MCP_SERVER.md @@ -14,6 +14,72 @@ DeerFlow supports configurable MCP servers and skills to extend its capabilities 3. Configure each server’s command, arguments, and environment variables as needed. 4. Restart the application to load and register MCP tools. +## OpenViking MCP Tools + +OpenViking's official server exposes a Streamable HTTP MCP endpoint at `/mcp`. +DeerFlow connects to it through the same generic MCP client used for other HTTP +servers: + +```json +{ + "mcpServers": { + "openviking": { + "enabled": true, + "type": "http", + "url": "http://127.0.0.1:1933/mcp", + "headers": { + "X-API-Key": "$OPENVIKING_API_KEY" + } + } + } +} +``` + +Set `OPENVIKING_API_KEY` to a normal owner-bound OpenViking **USER API key**. +The key determines the OpenViking account and user. Do not use a root/admin +key, trusted mode, or add `X-OpenViking-Account`, `X-OpenViking-User`, or +`X-OpenViking-Actor-Peer` headers for this personal single-owner setup. +`X-API-Key` is used here because DeerFlow expands a whole-string `$ENV_VAR` +value without storing a credential in the checked-in configuration. +If `OPENVIKING_API_KEY` is missing or empty during initialization, OpenViking +authentication fails and DeerFlow skips that MCP server, so no OpenViking tools +appear. Changing only the environment variable does not invalidate DeerFlow's +already-populated, file-signature-based MCP tool cache; after setting or fixing +the key, restart DeerFlow, modify and re-save the extensions config, or call the +MCP cache-reset endpoint at `POST /api/mcp/cache/reset`. + +OpenViking owns the tool schemas and behavior. DeerFlow performs the standard +MCP initialization and discovery flow, prefixes the discovered names with +`openviking_` by default, and routes calls back through the generic MCP client. +For capability parity with other official OpenViking harnesses, DeerFlow exposes +the native `forget` tool with the other discovered tools. `forget` permanently +deletes a `viking://` URI and should be called only after explicit user +confirmation; DeerFlow does not enforce that confirmation. + +Operators who do not want agents to call `forget` can block its default visible +name with DeerFlow's existing guardrail configuration: + +```yaml +guardrails: + enabled: true + provider: + use: deerflow.guardrails.builtin:AllowlistProvider + config: + denied_tools: ["openviking_forget"] +``` + +If `tool_name_prefix` is disabled for the OpenViking server, block `forget` +instead. + +This explicit tool path is separate from the automatic OpenViking memory backend +configured under `config.yaml -> memory`. Both may be enabled at the same time: +the memory backend handles automatic turn capture and recall, while MCP tools +are model-selected operations. + +For Docker, point `url` at the OpenViking address reachable from the Gateway +container, such as `http://openviking:1933/mcp` for a shared Compose network or +`http://host.docker.internal:1933/mcp` for a host-installed server. + ## Routing Hints Use `routing` when an MCP server should be preferred for specific requests, such diff --git a/backend/tests/test_openviking_mcp_integration.py b/backend/tests/test_openviking_mcp_integration.py new file mode 100644 index 000000000..5b96331f2 --- /dev/null +++ b/backend/tests/test_openviking_mcp_integration.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import asyncio +import json +import socket +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from copy import deepcopy +from pathlib import Path +from unittest.mock import patch + +import pytest +import uvicorn +from mcp.server.fastmcp import FastMCP + +from app.gateway.routers.mcp import McpServerConfigResponse, _mask_server_config +from deerflow.config.extensions_config import ExtensionsConfig +from deerflow.mcp.client import build_server_params +from deerflow.mcp.tools import get_mcp_tools + +_IDENTITY_HEADERS = { + "x-openviking-account", + "x-openviking-user", + "x-openviking-actor-peer", +} + + +class _RequestCapture: + def __init__(self, app) -> None: + self._app = app + self.headers: list[dict[str, str]] = [] + self.methods: list[str] = [] + + async def __call__(self, scope, receive, send) -> None: + if scope["type"] != "http": + await self._app(scope, receive, send) + return + + messages = [] + body = bytearray() + while True: + message = await receive() + messages.append(message) + body.extend(message.get("body", b"")) + if not message.get("more_body", False): + break + + self.headers.append({key.decode("latin-1").lower(): value.decode("latin-1") for key, value in scope.get("headers", [])}) + if body: + payload = json.loads(body) + requests = payload if isinstance(payload, list) else [payload] + self.methods.extend(request["method"] for request in requests if isinstance(request, dict) and "method" in request) + + message_iterator = iter(messages) + + async def replay_receive(): + return next(message_iterator, {"type": "http.disconnect"}) + + await self._app(scope, replay_receive, send) + + +@asynccontextmanager +async def _openviking_mcp_server() -> AsyncIterator[tuple[str, _RequestCapture, list[str]]]: + calls: list[str] = [] + mcp = FastMCP("OpenViking test server", stateless_http=True, json_response=True) + + @mcp.tool(name="find") + async def find(query: str) -> dict[str, list[str]]: + calls.append(query) + return {"matches": [query]} + + @mcp.tool(name="forget") + async def forget(uri: str) -> dict[str, str]: + return {"forgotten": uri} + + capture = _RequestCapture(mcp.streamable_http_app()) + server_socket = socket.socket() + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_socket.bind(("127.0.0.1", 0)) + server_socket.listen() + server_socket.setblocking(False) + port = server_socket.getsockname()[1] + server = uvicorn.Server( + uvicorn.Config( + capture, + log_level="error", + lifespan="on", + ) + ) + server_task = asyncio.create_task(server.serve(sockets=[server_socket])) + + try: + for _ in range(500): + if server.started: + break + if server_task.done(): + await server_task + await asyncio.sleep(0.01) + else: + raise TimeoutError("Timed out starting the test MCP server") + + yield f"http://127.0.0.1:{port}/mcp", capture, calls + finally: + server.should_exit = True + await server_task + server_socket.close() + + +def _write_openviking_extensions_config(path: Path, url: str) -> None: + example_path = Path(__file__).resolve().parents[2] / "extensions_config.example.json" + example = json.loads(example_path.read_text(encoding="utf-8")) + openviking = deepcopy(example["mcpServers"]["openviking"]) + openviking["enabled"] = True + openviking["url"] = url + path.write_text( + json.dumps({"mcpServers": {"openviking": openviking}}), + encoding="utf-8", + ) + + +def test_openviking_mcp_config_resolves_headers_omits_identity_and_masks_secrets( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + api_key = "openviking-user-test-secret" + monkeypatch.setenv("OPENVIKING_API_KEY", api_key) + config_path = tmp_path / "extensions_config.json" + _write_openviking_extensions_config(config_path, "http://127.0.0.1:1933/mcp") + + extensions = ExtensionsConfig.from_file(str(config_path)) + server = extensions.mcp_servers["openviking"] + params = build_server_params("openviking", server) + masked = _mask_server_config(McpServerConfigResponse.model_validate(server.model_dump())) + + assert params["headers"] == {"X-API-Key": api_key} + assert _IDENTITY_HEADERS.isdisjoint({name.lower() for name in params["headers"]}) + assert masked.headers == {"X-API-Key": "***"} + assert server.tools == {} + assert api_key not in masked.model_dump_json() + assert api_key not in caplog.text + + +@pytest.mark.asyncio +async def test_openviking_http_mcp_discovers_exposes_and_calls_native_tools( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + api_key = "openviking-user-test-secret" + monkeypatch.setenv("OPENVIKING_API_KEY", api_key) + + async with _openviking_mcp_server() as (url, capture, calls): + config_path = tmp_path / "extensions_config.json" + _write_openviking_extensions_config(config_path, url) + extensions = ExtensionsConfig.from_file(str(config_path)) + + with patch("deerflow.mcp.tools.ExtensionsConfig.from_file", return_value=extensions): + tools = await get_mcp_tools() + + assert {tool.name for tool in tools} == {"openviking_find", "openviking_forget"} + find_tool = next(tool for tool in tools if tool.name == "openviking_find") + result = await find_tool.ainvoke({"query": "needle"}) + + assert calls == ["needle"] + assert any(block.get("type") == "text" and "needle" in block.get("text", "") for block in result) + assert {"initialize", "notifications/initialized", "tools/list", "tools/call"} <= set(capture.methods) + assert capture.headers + for headers in capture.headers: + assert headers["x-api-key"] == api_key + assert _IDENTITY_HEADERS.isdisjoint(headers) + assert api_key not in caplog.text diff --git a/extensions_config.example.json b/extensions_config.example.json index 0f8639a6c..022e7c190 100644 --- a/extensions_config.example.json +++ b/extensions_config.example.json @@ -20,6 +20,15 @@ "tool_call_timeout": 60, "description": "GitHub MCP server for repository operations" }, + "openviking": { + "enabled": false, + "type": "http", + "url": "http://127.0.0.1:1933/mcp", + "headers": { + "X-API-Key": "$OPENVIKING_API_KEY" + }, + "description": "Official OpenViking tools for explicit memory and resource operations" + }, "postgres": { "enabled": false, "type": "stdio",