From ee8e6762acfc7ec1f5aab21c12fd4777768fab8d Mon Sep 17 00:00:00 2001 From: Hyeonsang Cho Date: Sat, 24 Oct 2026 17:12:04 +0900 Subject: [PATCH] fix(mcp): let ordinary users save bundled personal connections (#7400) * fix(mcp): let ordinary users save bundled personal connections The personal MCP policy read `server.cwd`, which McpServerConfigResponse does not declare. A bundled launcher definition (DingTalk, WeCom, HubSpot) never carries `cwd`, so for a non-administrator the check raised AttributeError and every create, edit or enable of one returned 500. The web UI installs catalog connections with scope "user", so an ordinary user could not install any of the three. Read a supplied `cwd` from the model's extras instead. A bundled launcher with a working directory still requires an administrator. * docs(changelog): reference #7400 for the bundled personal MCP fix * docs(changelog): say #7400 makes bundled personal installs work, not work again --------- Co-authored-by: Willem Jiang --- CHANGELOG.md | 10 +++- CHANGELOG_zh.md | 8 ++- backend/app/gateway/routers/personal_mcp.py | 4 +- backend/tests/test_personal_mcp.py | 64 +++++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2352629a65..4a91259a77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1397,12 +1397,19 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**. ### Fixed +- **mcp:** Ordinary users can now install DingTalk, WeCom and HubSpot from + **Capability Center → Plugins**. Saving one of these bundled + connections as a non-administrator, including editing or enabling it later, + failed with `500 Internal Server Error` because the personal-connection check + read a `cwd` field the request model does not declare. A bundled connection + with a working directory still requires an administrator. ([#7400]) + - **examples:** The extension example's package tests pass again. Its test registry lacked `knowledge_provider()`, so it no longer matched the `ExtensionRegistry` protocol and the README's `pytest` run failed. Extension Package Check now also runs the tests of bundled examples that ship them, so a contract change that breaks them fails its pull request. ([#7364]) - + - **gateway:** Run requests with a malformed body now get a client error instead of `500 Internal Server Error`. A `config.configurable` or `config.context` that is not an object, an invalid `assistant_id`, a @@ -13052,3 +13059,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#7326]: https://github.com/bytedance/deer-flow/pull/7326 [#7362]: https://github.com/bytedance/deer-flow/pull/7362 [#7364]: https://github.com/bytedance/deer-flow/pull/7364 +[#7400]: https://github.com/bytedance/deer-flow/pull/7400 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index f1d4be7fef..b91b51eaa2 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -1112,11 +1112,16 @@ ### 修复 +- **MCP:** 普通用户现在可以在“能力中心 → 插件”中安装钉钉、企业微信和 HubSpot。 + 此前非管理员保存这些内置连接(包括之后编辑或启用)会返回 + `500 Internal Server Error`,因为个人连接检查读取了请求模型未声明的 `cwd` 字段。 + 设置了工作目录的内置连接仍需管理员权限。([#7400]) + - **示例:** 扩展示例的包测试重新通过。其测试用 registry 缺少 `knowledge_provider()`,不再满足 `ExtensionRegistry` 协议,按 README 运行 `pytest` 会失败。Extension Package Check 现在也会运行自带测试的内置示例的测试, 因此破坏这些测试的契约变更会在其 PR 中失败。([#7364]) - + - **网关:** 请求体格式错误的运行请求现在返回客户端错误,而不是 `500 Internal Server Error`。此前 `config.configurable` 或 `config.context` 不是对象、`assistant_id` 无效、`agent_name` 不是字符串,或 AI 消息的 `tool_calls` @@ -10419,3 +10424,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#7326]: https://github.com/bytedance/deer-flow/pull/7326 [#7362]: https://github.com/bytedance/deer-flow/pull/7362 [#7364]: https://github.com/bytedance/deer-flow/pull/7364 +[#7400]: https://github.com/bytedance/deer-flow/pull/7400 diff --git a/backend/app/gateway/routers/personal_mcp.py b/backend/app/gateway/routers/personal_mcp.py index d4adbc6e01..5d82540052 100644 --- a/backend/app/gateway/routers/personal_mcp.py +++ b/backend/app/gateway/routers/personal_mcp.py @@ -50,7 +50,9 @@ def _validate_personal_server(server: mcp.McpServerConfigResponse, *, admin: boo if not admin: from deerflow.capabilities.business import is_bundled_connection - bundled = server.type == "stdio" and is_bundled_connection(server.command, server.args, server.env) and not server.cwd + # The response model does not declare `cwd`; a supplied one is an extra. + cwd = (server.model_extra or {}).get("cwd") + bundled = server.type == "stdio" and is_bundled_connection(server.command, server.args, server.env) and not cwd if (server.type not in {"http", "sse"} and not bundled) or server.oauth is not None: raise HTTPException(403, "Only an administrator may configure host commands or OAuth token endpoints") from deerflow.community.url_safety import validate_public_http_url diff --git a/backend/tests/test_personal_mcp.py b/backend/tests/test_personal_mcp.py index f665e6f8cd..0f237a5387 100644 --- a/backend/tests/test_personal_mcp.py +++ b/backend/tests/test_personal_mcp.py @@ -288,6 +288,70 @@ def test_untrusted_users_cannot_launch_packages_or_connect_to_private_hosts(pers assert read_user_mcp_config("alice")["mcpServers"]["github"]["personal_public_network"] is True +@pytest.mark.parametrize( + "provider,credentials", + [ + ("dingtalk", {"access_token": "robot-token", "sign_secret": "SEC-secret"}), + ("wecom", {"webhook_key": "webhook-secret"}), + ("hubspot", {"access_token": "private-token"}), + ], +) +def test_ordinary_users_install_and_manage_bundled_connections(personal_client, monkeypatch, tmp_path, provider, credentials): + from app.gateway.deps import get_config + from app.gateway.routers import capabilities + from deerflow.capabilities.business import is_bundled_connection + from deerflow.mcp.personal_access import requires_admin + + app = personal_client.app + app.include_router(capabilities.router) + app.dependency_overrides[get_config] = lambda: SimpleNamespace() + platform = tmp_path / "deployment.json" + platform.write_text(json.dumps({"mcpServers": {}})) + monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(platform)) + user = {"test-user": "alice", "test-role": "user"} + name = "my-" + provider + # The web UI installs every catalog connection with scope "user". + response = personal_client.post("/api/capabilities/installations", headers=user, json={"plugin_id": provider, "name": name, "scope": "user", "configuration": credentials}) + assert response.status_code == 200, response.text + assert [item["name"] for item in response.json()["items"]] == [name] + for secret in credentials.values(): + assert secret not in response.text + saved = next(iter(load_user_mcp_config("alice").mcp_servers.values())) + assert is_bundled_connection(saved.command, saved.args, saved.env) + assert set(saved.env.values()) == set(credentials.values()) + # Saved under ordinary-user policy, so discovery and calls need no admin role. + assert not requires_admin(saved) + + masked = personal_client.get("/api/mcp/personal/config", headers=user).json()["mcp_servers"][name] + masked["description"] = "Team notifications" + assert personal_client.put("/api/mcp/personal/config/server", headers=user, json={"server_name": name, "server": masked}).status_code == 200 + for enabled in (False, True): + assert personal_client.patch("/api/mcp/personal/config", headers=user, json={"server_name": name, "enabled": enabled}).status_code == 200 + saved = next(iter(load_user_mcp_config("alice").mcp_servers.values())) + assert saved.description == "Team notifications" + assert set(saved.env.values()) == set(credentials.values()) + assert personal_client.delete("/api/mcp/personal/config/servers/" + name, headers=user).status_code == 200 + assert read_user_mcp_config("alice")["mcpServers"] == {} + + +def test_bundled_launcher_with_a_working_directory_requires_an_administrator(personal_client): + from deerflow.capabilities.business import connection_config + + definition = {**connection_config("hubspot", {"access_token": "private-token"}), "cwd": "/srv"} + user = {"test-user": "alice", "test-role": "user"} + response = personal_client.post("/api/mcp/personal/config/servers", headers=user, json={"mcp_servers": {"crm": definition}}) + assert response.status_code == 403 + assert read_user_mcp_config("alice")["mcpServers"] == {} + + assert personal_client.post("/api/mcp/personal/config/servers", headers={"test-user": "alice"}, json={"mcp_servers": {"crm": definition}}).status_code == 200 + before = user_mcp_config_path("alice").read_bytes() + masked = personal_client.get("/api/mcp/personal/config", headers=user).json()["mcp_servers"]["crm"] + assert masked["cwd"] == "/srv" + assert personal_client.put("/api/mcp/personal/config/server", headers=user, json={"server_name": "crm", "server": masked}).status_code == 403 + assert personal_client.patch("/api/mcp/personal/config", headers=user, json={"server_name": "crm", "enabled": True}).status_code == 403 + assert user_mcp_config_path("alice").read_bytes() == before + + @pytest.mark.asyncio async def test_personal_network_rechecks_destination_before_each_request(monkeypatch): from deerflow.mcp import personal_network