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 <willem.jiang@gmail.com>
This commit is contained in:
Hyeonsang Cho 2026-10-24 17:12:04 +09:00 committed by GitHub
parent 7cfd3c57d2
commit ee8e6762ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 83 additions and 3 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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