From aa91016b51bb6b51805b61a23f9b489c7d24fa6e Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Fri, 6 Nov 2026 20:14:41 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(mcp):=20=E6=94=AF=E6=8C=81=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=B8=AA=E4=BA=BA=E5=87=AD=E8=AF=81=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E9=85=8D=E7=BD=AE=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 6 +- .../test/e2e/test_mcp_personal_credentials.py | 173 +++++++++++++++ .../services/test_app_initialization.py | 8 +- .../integration/services/test_mcp_oauth.py | 205 +++++++++++++++++- .../services/test_schema_migration_version.py | 39 +++- .../test_http_exception_passthrough.py | 4 +- .../unit/services/test_mcp_oauth_config.py | 18 +- backend/yuxi/api/routers/extensions/mcp.py | 27 ++- .../yuxi/api/routers/extensions/mcp_oauth.py | 24 +- .../yuxi/infrastructure/postgres/schema.py | 2 +- .../migrations/mcp_personal_credentials.py | 36 +++ backend/yuxi/modules/extensions/mcp/models.py | 2 + .../modules/extensions/mcp/oauth_config.py | 62 +++++- .../modules/extensions/mcp/oauth_runtime.py | 35 ++- .../modules/extensions/mcp/oauth_service.py | 61 +++++- .../yuxi/modules/extensions/mcp/repository.py | 20 +- .../yuxi/modules/extensions/mcp/runtime.py | 4 +- .../yuxi/modules/extensions/mcp/service.py | 32 +-- docs/agents/mcp-integration.md | 59 +++-- .../2026-11-06-mcp-personal-credentials.md | 58 +++++ docs/mechanisms/mcp.md | 34 +-- frontend/src/apis/mcp_api.js | 4 +- .../src/modules/extensions/model/mcpOAuth.js | 12 +- .../src/modules/extensions/ui/McpCardList.vue | 49 ++--- .../extensions/ui/McpConnectionControls.vue | 190 +++++++++++++--- .../modules/extensions/ui/McpDetailView.vue | 22 +- .../modules/extensions/ui/McpFormModal.vue | 69 +++++- .../fixtures/mcpPersonalCredentials.html | 29 +++ .../test/browser/mcpPersonalCredentials.js | 132 +++++++++++ frontend/test/unit/mcpFormSubmission.test.js | 17 +- .../test/unit/mcpPersonalCredentials.test.js | 65 ++++++ .../unit/resourceCreationContracts.test.js | 2 +- 32 files changed, 1304 insertions(+), 196 deletions(-) create mode 100644 backend/test/e2e/test_mcp_personal_credentials.py create mode 100644 backend/yuxi/migrations/mcp_personal_credentials.py create mode 100644 docs/develop-guides/decisions/implemented/2026-11-06-mcp-personal-credentials.md create mode 100644 frontend/test/browser/fixtures/mcpPersonalCredentials.html create mode 100644 frontend/test/browser/mcpPersonalCredentials.js create mode 100644 frontend/test/unit/mcpPersonalCredentials.test.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b6fd91d93..00a520c6f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -16,7 +16,7 @@ Yuxi 是一个面向 RAG、知识图谱和多智能体工作流的知识库平 - `api`:FastAPI API 服务,挂载 `backend/yuxi` 和测试目录;API 与 worker 的源码修改通过显式重启生效。 - `worker`:ARQ worker,执行已经派发的 AgentRun 与注册的 后台作业,并周期触发用户自建 Agent 定时任务;三者分别使用 PostgreSQL 中的运行租约、任务租约和调度锁闭合并发与恢复。 - `storage-init`:无网络的一次性进程,准备 API/worker/app-init 与 Milvus 挂载根的属主和模式,不递归修改已有文件;消费进程等待其成功退出。 -- `app-init`:普通用户运行的部署任务,在 PostgreSQL advisory lock 内先建立或校验当前 Schema,再同步系统配置定义、内置供应商、Agent、MCP 与 Skill 来源文件。API 与 worker 等待全部成功。空库基线为 business 1、knowledge 2,直接包含个人 MCP 授权表、128 位用户归属字段及当前约束。当前版本跳过结构初始化,其他版本明确拒绝;历史部署的结构调整由部署侧显式完成;知识域 v1 路径目录通过离线审计/转换入口升级 v2,运行进程不自动迁移。共享同步失败保留已提交内容,修正后幂等重跑。 +- `app-init`:普通用户运行的部署任务,在 PostgreSQL advisory lock 内先建立或校验当前 Schema,再同步系统配置定义、内置供应商、Agent、MCP 与 Skill 来源文件。API 与 worker 等待全部成功。空库基线为 business 2、knowledge 2,直接包含个人 MCP 授权表、128 位用户归属字段及当前约束。当前版本跳过结构初始化,其他版本明确拒绝;历史部署的结构调整由部署侧显式完成;知识域 v1 路径目录通过离线审计/转换入口升级 v2,运行进程不自动迁移。共享同步失败保留已提交内容,修正后幂等重跑。 - Cube Linux/KVM 执行节点:每个用户拥有一个持久普通账号 VM,Workspace 与授权 Skills 共享宿主文件。 - `sandbox-runner`:选择 container 后端时由该独立进程持有 Docker socket,为每个 Run 懒创建临时容器并执行命令;API/worker 只调用认证 HTTP。挂载、镜像和资源限制由 runner 拥有,Run 归属与清理责任仍由 PostgreSQL 拥有。 - `postgres`:业务数据、知识库元数据、持久 Input 队列、Turn/Run 与 LangGraph checkpoint。 @@ -51,7 +51,7 @@ Yuxi 默认交付完整知识能力。部署开关 `YUXI_KNOWLEDGE_ENABLED` 由 - `modules/identity` 拥有用户、部门、权限、凭据与 OIDC 账号用例,以及 Public API 的 Key 校验与 App/end_user 身份解析;`modules/extensions` 拥有 Skills、MCP 与工具目录;`modules/models` 拥有模型适配和供应商配置。 - `modules/schedules` 拥有用户定时 Agent 定义和 occurrence;`modules/background_jobs` 拥有任务登记、执行方上报的观察记录和取消意图;后台作业模块的 dispatch 与 registry 拥有提交和定义,`workers` 拥有领取、执行和失联维护;`modules/system` 拥有系统配置、Dashboard 与跨域共享资源所有权治理。 -`modules/extensions/mcp` 拥有远程服务器、个人 OAuth 连接和加密凭据。超管固定授权服务器信任根,浏览器事务绑定用户、浏览器和配置修订;回调结果由当前 JWT 用户确认后发布。PostgreSQL 连接行锁串行化刷新与断开,世代阻止旧回调恢复授权。MCP service 根据可信 runtime uid 统一解析本人的 MCP 可用性;Agent 与 Skill 只消费可用服务和工具,未连接时保持普通聊天可用,相关 Skill 依赖在执行边界关闭。工具目录归本次 Run 的 Context,服务按需发现一次后在运行内复用,新 Run 重新发现;每次出站复查当前用户、配置、工具和连接。 配置、协议、授权事务与个人 HTTP 适配分属对应模块,具体链路和适配边界见 [MCP 授权与运行身份](docs/mechanisms/mcp.md)。 +`modules/extensions/mcp` 拥有远程服务器、个人 OAuth/静态凭证连接和加密存储。超管固定授权服务器信任根,浏览器事务绑定用户、浏览器和配置修订;回调结果由当前 JWT 用户确认后发布。PostgreSQL 连接行锁串行化刷新与断开,世代阻止旧回调恢复授权。MCP service 根据可信 runtime uid 统一解析本人的 MCP 可用性;Agent 与 Skill 只消费可用服务和工具,未连接时保持普通聊天可用,相关 Skill 依赖在执行边界关闭。工具目录归本次 Run 的 Context,服务按需发现一次后在运行内复用,新 Run 重新发现;每次出站复查当前用户、配置、工具和连接。 配置、协议、授权事务与个人 HTTP 适配分属对应模块,具体链路和适配边界见 [MCP 授权与运行身份](docs/mechanisms/mcp.md)。 `modules/extensions/web` 拥有网络搜索、网页抓取与供应商适配;工具和管理员测试复用同一服务,每次调用读取当前有效配置,未配置的能力不进入运行工具集合。凭据与默认供应商复用 system 的持久设置和脱敏边界。 @@ -84,7 +84,7 @@ Yuxi 默认交付完整知识能力。部署开关 `YUXI_KNOWLEDGE_ENABLED` 由 - `apis` 是集中 HTTP 边界,复用 `base.js` 的请求、鉴权和错误处理。模块不能依赖 app/pages,shared 不能依赖业务模块或 API;ESLint 检查别名、相对路径和动态导入。跨模块只使用 `modules/` 的模型/用例公开入口或 `modules//ui/**/*.vue` 公开组件,不得直达 `model/` 或其他内部路径。 - 新 TypeScript 逻辑经 strict 类型检查,build 先执行 typecheck;现有 JavaScript 逐步迁移。sessionRuntime 按 Thread 拥有一份 item 状态,历史分页、SSE 与恢复快照写入同一 reducer,展示按需派生。sessionInput 拥有冻结提交、幂等重试和接收交接;输入队列观察者确认 Input 消费并触发持久运行恢复,共享 runtime 根据当前 Turn 选择唯一运行订阅。SessionWorkspace 负责编辑器草稿、附件消费与面板展示。视图存活期间的草稿分别保留,首次进入 Thread 从持久草稿初始化。独立 Thread 阅读与后续类型迁移见[重构提案](docs/develop-guides/decisions/proposed/2026-09-30-agent-view-frontend-refactor.md)。 -`/` 是公开首页;登录后的核心工作区是 `/agent`。`/extensions` 对所有登录用户开放,其中 Skills 与 MCP 个人连接对普通用户可见,知识库和工具仅管理员可见,MCP 配置仅超级管理员可修改;Dashboard 仅超级管理员可访问。后端权限检查始终是最终边界,前端守卫只负责页面体验。 +`/` 是公开首页;登录后的核心工作区是 `/agent`。`/extensions` 对所有登录用户开放,其中 Skills 与 MCP 个人连接对普通用户可见,知识库和工具仅管理员可见,MCP 配置仅超级管理员可修改,Token/API Key/OAuth 凭证由用户分别配置;Dashboard 仅超级管理员可访问。后端权限检查始终是最终边界,前端守卫只负责页面体验。 ## 智能体运行链路 diff --git a/backend/test/e2e/test_mcp_personal_credentials.py b/backend/test/e2e/test_mcp_personal_credentials.py new file mode 100644 index 000000000..67282cb2f --- /dev/null +++ b/backend/test/e2e/test_mcp_personal_credentials.py @@ -0,0 +1,173 @@ +"""个人静态凭证经过实际 API、worker 和工具协议的确定性验收。""" + +import json +from http.server import BaseHTTPRequestHandler +from uuid import uuid4 + +import asyncpg +import pytest + +from test.e2e.e2e_helpers import ( + archive_public_thread, + output_text, + postgres_dsn, + public_message, + register_replay_provider, + wait_for_consumed_input, + wait_for_terminal_turn, +) +from test.support.openai_replay_server import send_json_response, send_openai_sse_response, serve_replay_handler +from test.support.public_api import delete_agent + +pytestmark = [pytest.mark.asyncio, pytest.mark.e2e, pytest.mark.slow, pytest.mark.self_managed_resources] + + +@pytest.mark.parametrize("mode", ["bearer", "api_key"]) +async def test_personal_credentials_reach_worker_with_own_saved_secret(e2e_client, e2e_headers, mode): + """工具独立核对秘密,模型只转述真实返回,最终结果绑定 result Run。""" + client, headers = e2e_client, e2e_headers + suffix = uuid4().hex[:10] + slug, agent, provider = ("pytest-personal-" + suffix, "pytest-personal-agent-" + suffix, "personal-replay-" + suffix) + secret, identity = "fixture-personal-secret-" + suffix, "fixture-personal-identity-" + suffix + observed, thread, turn_id = [], None, None + db = await asyncpg.connect(postgres_dsn()) + + class Handler(BaseHTTPRequestHandler): + """远端凭证 oracle 与模型 wire oracle 共享一次性端口。""" + + def do_POST(self): + """MCP 核对认证头,模型核对实际工具结果且不能看到秘密。""" + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + if self.path.endswith("/mcp"): + header = "Authorization" if mode == "bearer" else "X-API-Key" + expected = "Bearer " + secret if mode == "bearer" else secret + if self.headers.get(header) != expected: + send_json_response(self, 401, {}) + return + if "id" not in body: + self.send_response(202) + self.send_header("Content-Length", "0") + self.end_headers() + return + method = body["method"] + if method == "initialize": + result = { + "protocolVersion": body["params"]["protocolVersion"], + "capabilities": {"tools": {}}, + "serverInfo": {"name": "personal-fixture", "version": "1"}, + } + elif method == "tools/list": + result = { + "tools": [ + { + "name": "personal_identity", + "description": "读取本人身份", + "inputSchema": {"type": "object", "properties": {}}, + } + ] + } + else: + assert method == "tools/call" and body["params"]["name"] == "personal_identity" + observed.append(identity) + result = {"content": [{"type": "text", "text": identity}], "isError": False} + send_json_response(self, 200, {"jsonrpc": "2.0", "id": body["id"], "result": result}) + return + assert secret not in json.dumps(body) + tools = [message for message in body["messages"] if message["role"] == "tool"] + if tools: + assert identity in str(tools[-1]["content"]) + delta, finish = {"role": "assistant", "content": "PERSONAL_MCP_OK:" + identity}, "stop" + else: + name = next(item["function"]["name"] for item in body["tools"] if "personal_identity" in item["function"]["name"]) + delta = { + "role": "assistant", + "tool_calls": [{"index": 0, "id": "personal-call", "type": "function", "function": {"name": name, "arguments": "{}"}}], + } + finish = "tool_calls" + common = {"id": "personal", "object": "chat.completion.chunk", "created": 1, "model": "deterministic-chat"} + send_openai_sse_response( + self, + [ + {**common, "choices": [{"index": 0, "delta": delta, "finish_reason": None}]}, + { + **common, + "choices": [{"index": 0, "delta": {}, "finish_reason": finish}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + ], + ) + + def log_message(self, *_): + """测试不输出身份、凭据或请求正文。""" + + with serve_replay_handler(Handler) as replay: + try: + await register_replay_provider(client, headers, base_url=replay, provider_id=provider) + # 一次性 HTTP fixture 直接登记可信连接,不修改部署的实验来源白名单。 + # HTTPS 配置边界由 integration 单独证明;此处验证跨 worker 凭证与结果。 + await db.execute( + "INSERT INTO mcp_servers (slug,name,url,transport,auth_mode,api_key_header,headers,enabled,created_by,updated_by) " + "VALUES ($1,'个人凭证验收',$2,'streamable_http',$3,$4,'{}',1,'fixture','fixture')", + slug, + replay + "/mcp", + mode, + "X-API-Key" if mode == "api_key" else None, + ) + configured = await client.get("/api/system/mcp-servers/" + slug, headers=headers) + assert configured.status_code == 200 + saved = await client.put( + "/api/system/mcp-servers/" + slug + "/connection", + headers=headers, + json={ + "secret": secret, + "config_revision": configured.json()["data"]["config_revision"], + }, + ) + assert saved.status_code == 200 + created = await client.post( + "/api/agent", + headers=headers, + json={ + "slug": agent, + "name": "个人凭证验收", + "backend_id": "ChatbotAgent", + "config_json": { + "context": { + "model": provider + ":deterministic-chat", + "mcps": [slug], + "tools": [], + "knowledges": [], + "skills": [], + "preload_skills": [], + } + }, + }, + ) + assert created.status_code == 200, created.text + response = await client.post( + "/api/v1/agents/sessions", + headers={**headers, "Idempotency-Key": suffix}, + json={ + "agent_id": agent, + "title": "pytest-personal-" + suffix, + "input": [public_message("读取本人身份")], + }, + ) + assert response.status_code == 201, response.text + accepted = await wait_for_consumed_input(client, headers, response.json()["yuxi"]["receipt"]) + thread, turn_id = accepted["session_id"], accepted["turn_id"] + turn = await wait_for_terminal_turn(client, headers, thread, turn_id) + assert turn["status"] == "completed" and identity in output_text(turn["yuxi"]["output"]) + run = turn["yuxi"]["result_run_id"] + assert run == accepted["run_id"] and observed == [identity] + record = await db.fetchrow("SELECT status,error_message FROM agent_runs WHERE id=$1", run) + assert record["status"] == "completed" and record["error_message"] is None + finally: + try: + if thread: + await archive_public_thread(client, headers, thread, turn_id=turn_id) + await delete_agent(client, headers, agent) + await client.delete("/api/system/mcp-servers/" + slug, headers=headers) + await client.delete("/api/system/model-providers/" + provider, headers=headers) + finally: + await db.close() diff --git a/backend/test/integration/services/test_app_initialization.py b/backend/test/integration/services/test_app_initialization.py index c2f382fca..7274ca019 100644 --- a/backend/test/integration/services/test_app_initialization.py +++ b/backend/test/integration/services/test_app_initialization.py @@ -71,7 +71,7 @@ async def test_fresh_app_and_rerun_preserve_admin_values_and_shared_files(isolat """完整初始化可重复执行,代码拥有的文件收敛且管理员字段保留。""" manager, engine, root = isolated_app await app_init.main() - assert await get_schema_versions(manager) == {"business": 1, "knowledge": 2} + assert await get_schema_versions(manager) == {"business": 2, "knowledge": 2} before = await persisted_facts(engine, root) async with engine.begin() as conn: await conn.execute(text("UPDATE config_options SET value='{\"admin\":\"preserve-this-value\"}', updated_by='admin-sentinel'")) @@ -114,7 +114,7 @@ async def test_failed_shared_stage_preserves_schema_and_retry_finishes(isolated_ monkeypatch.setattr(app_init, "init_builtin_skills", AsyncMock(side_effect=OSError("fixture storage unavailable"))) with pytest.raises(OSError, match="fixture storage unavailable"): await app_init.main() - assert await get_schema_versions(manager) == {"business": 1, "knowledge": 2} + assert await get_schema_versions(manager) == {"business": 2, "knowledge": 2} async with engine.begin() as conn: assert await conn.scalar(text("SELECT count(*) FROM agents")) > 0 await conn.execute(text("INSERT INTO knowledge_bases (kb_id,name,kb_type) VALUES ('retained','Retained','milvus')")) @@ -166,7 +166,7 @@ async def run(): assert "InvalidRequestError" in result.stderr and "Project" in result.stderr else: assert result.returncode == 0, result.stderr - assert await get_schema_versions(manager) == {"business": 1, "knowledge": 2} + assert await get_schema_versions(manager) == {"business": 2, "knowledge": 2} await persisted_facts(engine, root) @@ -248,7 +248,7 @@ async def test_mineru_upgrade_persists_tier_without_overwriting_admin_fields(iso assert ( await conn.execute(text("SELECT value, updated_by, updated_at FROM config_options WHERE key='mineru_ocr_host_opts'")) ).one() == row - assert await get_schema_versions(manager) == {"business": 1, "knowledge": 2} + assert await get_schema_versions(manager) == {"business": 2, "knowledge": 2} async def test_invalid_legacy_mineru_config_fails_without_replacing_value(isolated_app): diff --git a/backend/test/integration/services/test_mcp_oauth.py b/backend/test/integration/services/test_mcp_oauth.py index 32ab3cfa1..280d8aec1 100644 --- a/backend/test/integration/services/test_mcp_oauth.py +++ b/backend/test/integration/services/test_mcp_oauth.py @@ -3,6 +3,7 @@ import asyncio import base64 import hashlib +import json import os import secrets import socket @@ -18,7 +19,7 @@ import uvicorn from cryptography.fernet import Fernet from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, Response, StreamingResponse from sqlalchemy import select, text from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine @@ -135,7 +136,10 @@ async def token(request: Request): @app.post("/mcp") async def mcp_protocol(request: Request): """协议结果包含 token 对应员工,用于观察缓存隔离。""" - authorization = request.headers.get("authorization", "").removeprefix("Bearer ") + if current.get("error_status"): + return JSONResponse({}, status_code=current["error_status"]) + authorization = request.headers.get(current.get("auth_header", "authorization"), "").removeprefix("Bearer ") + current.setdefault("received_credentials", []).append(authorization) if authorization == current.get("delayed_reject_token"): current["rejection_started"].set() await current["rejection_release"].wait() @@ -144,8 +148,6 @@ async def mcp_protocol(request: Request): return JSONResponse({}, status_code=401) payload = await request.json() if "id" not in payload: - from fastapi.responses import Response - return Response(status_code=202) method = payload["method"] previous_method = current.get("last_mcp_method") @@ -175,6 +177,39 @@ async def mcp_protocol(request: Request): raise AssertionError(method) return {"jsonrpc": "2.0", "id": payload["id"], "result": result} + sse_sessions = {} + + @app.get("/sse") + async def sse_connection(request: Request): + """SSE 使用独立队列回传协议消息,认证仍由每次请求提供。""" + if current.get("error_status"): + return JSONResponse({}, status_code=current["error_status"]) + credential = request.headers.get(current.get("auth_header", "authorization"), "").removeprefix("Bearer ") + if credential not in identities: + return JSONResponse({}, status_code=401) + session = uuid4().hex + queue = sse_sessions[session] = asyncio.Queue() + + async def events(): + """只发布同来源 endpoint,并在断开时释放队列。""" + try: + yield f"event: endpoint\ndata: {root}/sse/messages?session_id={session}\n\n" + while True: + yield "event: message\ndata: " + await queue.get() + "\n\n" + finally: + sse_sessions.pop(session, None) + + return StreamingResponse(events(), media_type="text/event-stream") + + @app.post("/sse/messages") + async def sse_message(request: Request, session_id: str): + """复用同一独立协议 oracle 验证 SSE POST 身份。""" + result = await mcp_protocol(request) + if isinstance(result, Response): + return result + await sse_sessions[session_id].put(json.dumps(result)) + return Response(status_code=202) + server = uvicorn.Server(uvicorn.Config(app, lifespan="off", log_level="critical", access_log=False)) task = None try: @@ -230,6 +265,7 @@ async def runtime_session(): "schema": schema, "headers": headers, "codes": codes, + "identities": identities, "current": current, "root": root, "users": users, @@ -1188,3 +1224,164 @@ async def main(): asyncio.run(main()) """ + + +@pytest.mark.parametrize("mode,transport", [("bearer", "streamable_http"), ("api_key", "streamable_http"), ("api_key", "sse")]) +async def test_personal_static_credentials_use_own_identity_and_revoke_old_tools(scenario, mode, transport): + """真实接口、密文和工具返回共同证明个人静态凭证的隔离与撤销。""" + http, headers, current = scenario["http"], scenario["headers"], scenario["current"] + base = "/api/system/mcp-servers" + current["auth_header"] = "x-api-key" if mode == "api_key" else "authorization" + response = await http.put( + base + "/fixture", + headers=headers["admin"], + json={ + "auth_mode": mode, + "api_key_header": "X-API-Key" if mode == "api_key" else None, + "headers": {}, + "transport": transport, + "url": scenario["root"] + ("/sse" if transport == "sse" else "/mcp"), + }, + ) + assert response.status_code == 200, response.text + revision = response.json()["data"]["config_revision"] + scenario["identities"].update({"alice-key": "alice", "bob-key": "bob", "alice-new-key": "alice-new"}) + for user in ("alice", "bob"): + response = await http.put( + base + "/fixture/connection", + headers=headers[user], + json={ + "secret": user + "-key", + "config_revision": revision, + }, + ) + assert response.json() == {"success": True, "connection_status": "connected"} + async with scenario["db"]() as db: + connections = (await db.scalars(select(MCPConnection).order_by(MCPConnection.user_id))).all() + assert len(connections) == 2 + assert {decrypt(row.credentials)["secret"] for row in connections} == {"alice-key", "bob-key"} + assert all("alice-key" not in row.credentials and "bob-key" not in row.credentials for row in connections) + alice_generation = next(row.generation for row in connections if row.user_id == scenario["users"][0].id) + listing = await http.get(base, headers=headers["alice"]) + assert listing.json()["data"][0]["config_revision"] == revision + assert "alice-key" not in listing.text and "bob-key" not in listing.text + received = len(current.get("received_credentials", [])) + assert (await http.post(base + "/fixture/test", headers=headers["admin"])).status_code == 502 + assert len(current.get("received_credentials", [])) == received + assert (await http.post(base + "/fixture/test", headers=headers["alice"])).status_code == 200 + alice_tools = await get_enabled_mcp_tools("fixture", uid="alice") + bob_tools = await get_enabled_mcp_tools("fixture", uid="bob") + assert "alice" in str(await alice_tools[0].ainvoke({})) + assert "bob" in str(await bob_tools[0].ainvoke({})) + response = await http.put( + base + "/fixture/connection", + headers=headers["alice"], + json={ + "secret": "alice-new-key", + "config_revision": revision, + }, + ) + assert response.status_code == 200 + async with scenario["db"]() as db: + await MCPOAuthService(db).reject_credentials("fixture", "alice", alice_generation) + assert "alice-new" in str(await alice_tools[0].ainvoke({})) + assert current["refresh_count"] == 0 and current["register_count"] == 0 + + # 权限不足与临时网络故障不能删除已保存的个人密文。 + current["error_status"] = 403 + assert (await http.post(base + "/fixture/test", headers=headers["alice"])).status_code == 502 + current.pop("error_status") + async with scenario["db"]() as db: + own = await db.scalar(select(MCPConnection).where(MCPConnection.user_id == scenario["users"][0].id)) + assert own.status == "connected" and decrypt(own.credentials)["secret"] == "alice-new-key" + + await http.delete(base + "/fixture/connection", headers=headers["alice"]) + received = len(current["received_credentials"]) + with pytest.raises(Exception): + await alice_tools[0].ainvoke({}) + assert len(current["received_credentials"]) == received + assert "bob" in str(await bob_tools[0].ainvoke({})) + await http.put(base + "/fixture/status", headers=headers["admin"], json={"enabled": False}) + assert (await http.get(base, headers=headers["alice"])).json()["data"] == [] + bob_listing = (await http.get(base, headers=headers["bob"])).json()["data"] + assert len(bob_listing) == 1 and bob_listing[0]["enabled"] is False + assert (await http.post(base + "/fixture/test", headers=headers["bob"])).status_code == 502 + await http.delete(base + "/fixture/connection", headers=headers["bob"]) + assert (await http.get(base, headers=headers["bob"])).json()["data"] == [] + async with scenario["db"]() as db: + rows = (await db.scalars(select(MCPConnection))).all() + assert all(row.credentials is None and row.status == "disconnected" for row in rows) + generations = {row.user_id: row.generation for row in rows} + changed = await http.put(base + "/fixture", headers=headers["admin"], json={"headers": {"X-Changed": "yes"}}) + assert changed.status_code == 200 + for user in ("alice", "bob"): + assert (await http.get(base, headers=headers[user])).json()["data"] == [] + async with scenario["db"]() as db: + rows = (await db.scalars(select(MCPConnection))).all() + assert all(row.status == "disconnected" and row.credentials is None for row in rows) + assert all(row.generation == generations[row.user_id] + 1 for row in rows) + + +async def test_static_credential_input_and_stale_revision_cannot_publish_secrets(scenario): + """输入错误不回显秘密,旧表单和伪造身份不能写入当前连接。""" + http, headers = scenario["http"], scenario["headers"] + base = "/api/system/mcp-servers/fixture" + response = await http.put(base, headers=headers["admin"], json={"auth_mode": "bearer", "headers": {}}) + revision = response.json()["data"]["config_revision"] + for body in ( + {"secret": "private-key", "config_revision": revision, "user_id": scenario["users"][1].id}, + {"secret": "private-key\n", "config_revision": revision}, + {"secret": "private-key" * 4000, "config_revision": revision}, + {"secret": "", "config_revision": revision}, + ): + invalid = await http.put(base + "/connection", headers=headers["alice"], json=body) + assert invalid.status_code == 422 and "private-key" not in invalid.text + assert (await http.put(base, headers=headers["alice"], json={"url": "https://other.example/mcp"})).status_code == 403 + for update in ( + {"auth_mode": "api_key", "api_key_header": "Host"}, + {"headers": {"aUtHoRiZaTiOn": "Bearer shared"}}, + ): + invalid = await http.put(base, headers=headers["admin"], json=update) + assert invalid.status_code == 400 + changed = await http.put(base, headers=headers["admin"], json={"url": scenario["root"] + "/mcp?changed=1"}) + assert changed.status_code == 200 + stale = await http.put(base + "/connection", headers=headers["alice"], json={"secret": "private-key", "config_revision": revision}) + assert stale.status_code == 409 and "private-key" not in stale.text + async with scenario["db"]() as db: + assert (await db.scalars(select(MCPConnection))).all() == [] + + +async def test_static_401_revokes_only_current_connection_and_other_origins_are_rejected(scenario): + """远端 401 形成重连状态,跨来源请求在发送凭证前拒绝。""" + from yuxi.modules.extensions.mcp.oauth_runtime import PersonalMCPAuth + + http, headers = scenario["http"], scenario["headers"] + base = "/api/system/mcp-servers/fixture" + response = await http.put(base, headers=headers["admin"], json={"auth_mode": "bearer", "headers": {}}) + revision = response.json()["data"]["config_revision"] + saved = await http.put( + base + "/connection", headers=headers["alice"], json={"secret": "fixture-invalid-key", "config_revision": revision} + ) + assert saved.status_code == 200 + auth = PersonalMCPAuth("fixture", "alice", revision, auth_mode="bearer", url=scenario["root"] + "/mcp") + async with httpx.AsyncClient(auth=auth, trust_env=False) as client: + with pytest.raises(StateConflictError, match="来源"): + await client.post(scenario["root"].replace("127.0.0.1", "localhost") + "/mcp", json={}) + assert scenario["current"].get("received_credentials", []) == [] + failed = await http.post(base + "/test", headers=headers["alice"]) + assert failed.status_code == 502 + async with scenario["db"]() as db: + own = await db.scalar(select(MCPConnection).where(MCPConnection.user_id == scenario["users"][0].id)) + assert own.status == "reconnect_required" and own.credentials is None + + +async def test_explicit_anonymous_mode_does_not_require_a_personal_connection(scenario): + """无认证方式仍走当前服务策略,不发送共享或个人凭证。""" + http, headers = scenario["http"], scenario["headers"] + response = await http.put("/api/system/mcp-servers/fixture", headers=headers["admin"], json={"auth_mode": "none", "headers": {}}) + assert response.status_code == 200 + scenario["identities"][""] = "anonymous-result" + tools = await get_enabled_mcp_tools("fixture") + assert "anonymous-result" in str(await tools[0].ainvoke({})) + assert set(scenario["current"]["received_credentials"]) == {""} + assert (await http.post("/api/system/mcp-servers/fixture/test", headers=headers["alice"])).status_code == 403 diff --git a/backend/test/integration/services/test_schema_migration_version.py b/backend/test/integration/services/test_schema_migration_version.py index 390173ff1..bc7b7eca5 100644 --- a/backend/test/integration/services/test_schema_migration_version.py +++ b/backend/test/integration/services/test_schema_migration_version.py @@ -176,7 +176,7 @@ async def test_fresh_business_schema_contains_input_lifecycle_without_request_ta "thread_id", } <= input_columns assert "conversation_thread_id" not in input_columns - assert BUSINESS_SCHEMA_VERSION == 1 + assert BUSINESS_SCHEMA_VERSION == 2 assert KNOWLEDGE_SCHEMA_VERSION == 2 finally: await _drop_isolated_schema(schema, admin_engine, scoped_engine) @@ -539,3 +539,40 @@ async def test_fresh_initializer_creates_complete_baseline_and_restarts_without_ finally: await pool.close() await _drop_isolated_schema(schema, admin, engine) + + +@pytest.mark.parametrize("version", [1, 99]) +async def test_mcp_credential_upgrade_is_explicit_and_preserves_existing_data(version): + """真实 PostgreSQL 证明重复升级保留旧值,未知版本在 DDL 前拒绝。""" + from yuxi.migrations.mcp_personal_credentials import upgrade_mcp_personal_credentials + + schema, admin, engine, manager = await _create_isolated_manager("pytest_mcp_upgrade") + try: + await create_schema_version_table(manager) + await record_schema_version(manager, "business", version) + async with engine.begin() as conn: + await conn.execute(text("CREATE TABLE mcp_servers (slug TEXT PRIMARY KEY, headers JSONB)")) + await conn.execute(text("INSERT INTO mcp_servers VALUES ('existing', '{\"X-API-Key\":\"fixture-shared\"}')")) + if version == 99: + with pytest.raises(RuntimeError, match="business v1/v2"): + await upgrade_mcp_personal_credentials(manager) + else: + await upgrade_mcp_personal_credentials(manager) + await upgrade_mcp_personal_credentials(manager) + async with engine.connect() as conn: + assert await conn.scalar(text("SELECT headers FROM mcp_servers WHERE slug='existing'")) == {"X-API-Key": "fixture-shared"} + columns = ( + ( + await conn.execute( + text( + "SELECT column_name FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='mcp_servers'" + ) + ) + ) + .scalars() + .all() + ) + assert ("api_key_header" in columns) == (version == 1) + assert (await get_schema_versions(manager))["business"] == (2 if version == 1 else 99) + finally: + await _drop_isolated_schema(schema, admin, engine) diff --git a/backend/test/unit/routers/test_http_exception_passthrough.py b/backend/test/unit/routers/test_http_exception_passthrough.py index db18245d8..f9d3cc669 100644 --- a/backend/test/unit/routers/test_http_exception_passthrough.py +++ b/backend/test/unit/routers/test_http_exception_passthrough.py @@ -87,11 +87,11 @@ async def test_route_preserves_http_exception(monkeypatch, status_code, endpoint async def test_nested_mcp_connection_preserves_http_exception(monkeypatch): """内层连接捕获不能把 429 改写成 500。""" error = HTTPException(429, "请求过于频繁", headers={"Retry-After": "30"}) - monkeypatch.setattr(mcp_router, "get_server_or_404", AsyncMock(return_value=SimpleNamespace())) + monkeypatch.setattr(mcp_router, "get_server_or_404", AsyncMock(return_value=SimpleNamespace(auth_mode="headers"))) monkeypatch.setattr(mcp_router, "inspect_mcp_server_tools", AsyncMock(side_effect=error)) with pytest.raises(HTTPException) as caught: - await mcp_router.test_mcp_server("remote", current_user=SimpleNamespace(uid="user-1"), db=None) + await mcp_router.test_mcp_server("remote", current_user=SimpleNamespace(uid="user-1", role="superadmin"), db=None) assert caught.value is error diff --git a/backend/test/unit/services/test_mcp_oauth_config.py b/backend/test/unit/services/test_mcp_oauth_config.py index 49cc4ea6e..48c487f56 100644 --- a/backend/test/unit/services/test_mcp_oauth_config.py +++ b/backend/test/unit/services/test_mcp_oauth_config.py @@ -97,7 +97,7 @@ def test_oauth_configuration_rejects_shared_authorization_and_does_not_reuse_oth oauth_registration=None, ) config = {"issuer_url": "https://sso.example", "client_id": "first", "client_secret": "private-secret"} - with pytest.raises(ValueError, match="Authorization"): + with pytest.raises(ValueError, match="共享请求头"): configure_mcp_auth(server, "oauth", config) server.headers = {} configure_mcp_auth(server, "oauth", config) @@ -134,3 +134,19 @@ def test_production_rejects_http_even_when_lab_origins_are_present(monkeypatch, monkeypatch.setenv("MCP_OAUTH_LAB_HTTP_ORIGINS", "http://sso.example") with pytest.raises(ValueError): validate_url("http://sso.example/issuer") + + +def test_nullable_api_key_header_preserves_existing_oauth_revision(): + """新增空字段不能使业务库 v1 保存的 OAuth 修订失效。""" + from yuxi.modules.extensions.mcp.models import MCPServer + from yuxi.modules.extensions.mcp.oauth_config import config_revision + + server = MCPServer( + url="https://example.com/mcp", + transport="streamable_http", + headers={}, + auth_mode="oauth", + oauth_config={"issuer_url": "https://sso.example", "client_id": "fixture"}, + oauth_secret=None, + ) + assert config_revision(server) == "1d3730d3b3fe9ede84c0c02f29ea197489f1bb8c143fec1065f5cb2c445e28f7" diff --git a/backend/yuxi/api/routers/extensions/mcp.py b/backend/yuxi/api/routers/extensions/mcp.py index 2c9a82188..aef45f0c5 100644 --- a/backend/yuxi/api/routers/extensions/mcp.py +++ b/backend/yuxi/api/routers/extensions/mcp.py @@ -7,6 +7,7 @@ from yuxi.api.dependencies.auth import get_db, get_required_user, get_superadmin_user from yuxi.api.routers.extensions.mcp_oauth import router as oauth_router from yuxi.infrastructure.observability.logging import logger +from yuxi.modules.extensions.mcp.oauth_config import PERSONAL_AUTH_MODES, STATIC_AUTH_MODES, config_revision from yuxi.modules.extensions.mcp.repository import get_all_mcp_servers, get_mcp_server from yuxi.modules.extensions.mcp.service import ( MCPServerNotFoundError, @@ -16,11 +17,13 @@ inspect_mcp_server_tools, is_builtin_mcp_server, personal_connection_states, + runtime_mcp_failure, set_server_enabled, toggle_tool_enabled, update_mcp_server, ) from yuxi.modules.identity.models import User +from yuxi.shared.errors import DomainError mcp = APIRouter(prefix="/system/mcp-servers", tags=["mcp"]) mcp.include_router(oauth_router) @@ -34,6 +37,7 @@ class CreateMcpServerRequest(BaseModel): auth_mode: str = "headers" oauth_config: dict | None = None + api_key_header: str | None = None slug: str = Field(..., description="稳定标识") name: str = Field(..., description="展示名称") transport: str = Field(..., description="传输类型:sse/streamable_http") @@ -49,6 +53,7 @@ class UpdateMcpServerRequest(BaseModel): auth_mode: str | None = None oauth_config: dict | None = None + api_key_header: str | None = None name: str | None = Field(None, description="展示名称") transport: str | None = Field(None, description="传输类型") @@ -83,12 +88,17 @@ async def get_mcp_servers( data = [] for server in servers: - if not server.enabled: + if not server.enabled and states[server.slug] not in {"connected", "reconnect_required"}: continue data.append( { "slug": server.slug, "auth_mode": server.auth_mode, + **( + {"config_revision": config_revision(server), "api_key_header": server.api_key_header} + if server.auth_mode in STATIC_AUTH_MODES + else {} + ), "connection_status": states[server.slug], "can_manage": False, "name": getattr(server, "name", ""), @@ -133,6 +143,7 @@ async def create_mcp_server_route( created_by=current_user.username, auth_mode=request.auth_mode, oauth_config=request.oauth_config, + api_key_header=request.api_key_header, ) return {"success": True, "data": serialize_mcp_server(server)} except ValueError: @@ -188,6 +199,7 @@ async def update_mcp_server_route( updated_by=current_user.username, auth_mode=request.auth_mode, oauth_config=request.oauth_config, + api_key_header=request.api_key_header, ) return {"success": True, "data": serialize_mcp_server(server)} except HTTPException: @@ -232,12 +244,14 @@ async def delete_mcp_server_route( @mcp.post("/{slug}/test") async def test_mcp_server( slug: str, - current_user: User = Depends(get_superadmin_user), + current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): """测试 MCP 服务器连接""" try: server = await get_server_or_404(db, slug) + if server.auth_mode not in PERSONAL_AUTH_MODES and current_user.role != "superadmin": + raise HTTPException(status_code=403, detail="只有超级管理员可以测试共享或内置服务") try: tools = await inspect_mcp_server_tools(server, uid=current_user.uid) return { @@ -247,9 +261,10 @@ async def test_mcp_server( } except HTTPException: raise - except Exception: - raise HTTPException(status_code=502, detail="MCP 连接失败,请检查服务地址、凭据和网络后重试") from None - except HTTPException: + except Exception as exc: + reason, _ = runtime_mcp_failure(exc) + raise HTTPException(status_code=502, detail={"code": "mcp_connection_failed", "message": reason}) from None + except (HTTPException, DomainError): raise except Exception as e: logger.error(f"Failed to test MCP server: {e}") @@ -375,6 +390,8 @@ async def get_server_or_404(db: AsyncSession, slug: str): def serialize_mcp_server(server) -> dict: """序列化 MCP,并补充代码内置标识。""" data = server.to_dict() + if data.get("auth_mode") in STATIC_AUTH_MODES: + data["config_revision"] = config_revision(server) data["can_manage"] = True data["is_builtin"] = is_builtin_mcp_server(server) return data diff --git a/backend/yuxi/api/routers/extensions/mcp_oauth.py b/backend/yuxi/api/routers/extensions/mcp_oauth.py index d46af36cd..e692c0984 100644 --- a/backend/yuxi/api/routers/extensions/mcp_oauth.py +++ b/backend/yuxi/api/routers/extensions/mcp_oauth.py @@ -4,9 +4,9 @@ import secrets from urllib.parse import urlencode -from fastapi import APIRouter, Depends, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import RedirectResponse -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, StrictStr, ValidationError from yuxi.api.dependencies.auth import get_db, get_required_user from yuxi.modules.extensions.mcp.oauth_config import redirect_uri @@ -26,6 +26,26 @@ class CompleteMCPAuthorization(BaseModel): result: str = Field(min_length=20, max_length=100) +class SaveMCPCredentials(BaseModel): + """用户只提交秘密与打开表单时的配置修订。""" + + model_config = ConfigDict(extra="forbid") + secret: StrictStr = Field(min_length=1, max_length=32768, pattern=r"^[!-~]+$", repr=False) + config_revision: str = Field(min_length=64, max_length=64) + + +@router.put("/{slug}/connection") +async def save_credentials(slug: str, request: Request, response: Response, user=Depends(get_required_user), db=Depends(get_db)): + """保存本人凭证,验证错误不回显原始 secret。""" + try: + body = SaveMCPCredentials.model_validate(await request.json()) + except (ValueError, ValidationError): + raise HTTPException(status_code=422, detail="凭证格式无效,请填写原始 Token 或 API Key 并重新打开过期表单") from None + status = await MCPOAuthService(db).save_credentials(slug, user.id, body.secret, body.config_revision) + response.headers.update(NO_STORE) + return {"success": True, "connection_status": status} + + @router.get("/oauth/callback") async def callback(request: Request, state: str = "", code: str = "", error: str = "", db=Depends(get_db)): """浏览器回调固定返回 MCP 页,授权码不交给前端。""" diff --git a/backend/yuxi/infrastructure/postgres/schema.py b/backend/yuxi/infrastructure/postgres/schema.py index beffb6dcc..9aeddc003 100644 --- a/backend/yuxi/infrastructure/postgres/schema.py +++ b/backend/yuxi/infrastructure/postgres/schema.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: from yuxi.infrastructure.postgres.manager import PostgresManager -BUSINESS_SCHEMA_VERSION = 1 +BUSINESS_SCHEMA_VERSION = 2 KNOWLEDGE_SCHEMA_VERSION = 2 SCHEMA_VERSION_TABLE = "yuxi_schema_migrations" diff --git a/backend/yuxi/migrations/mcp_personal_credentials.py b/backend/yuxi/migrations/mcp_personal_credentials.py new file mode 100644 index 000000000..ee7068c37 --- /dev/null +++ b/backend/yuxi/migrations/mcp_personal_credentials.py @@ -0,0 +1,36 @@ +"""显式升级个人 MCP 凭证需要的业务库字段。""" + +import asyncio + +from sqlalchemy import text + +from yuxi.infrastructure.postgres.manager import PostgresManager +from yuxi.infrastructure.postgres.schema import get_schema_versions +from yuxi.migrations.schema import schema_migration_lock + + +async def upgrade_mcp_personal_credentials(manager: PostgresManager) -> None: + """在初始化锁内幂等升级 business v1/v2,保留既有凭证。""" + async with schema_migration_lock(manager): + versions = await get_schema_versions(manager) + if versions.get("business") not in {1, 2} or "initializing" in versions: + raise RuntimeError("个人 MCP 凭证升级需要已完成初始化的 business v1/v2") + async with manager.async_engine.begin() as conn: + await conn.execute(text("ALTER TABLE mcp_servers ADD COLUMN IF NOT EXISTS api_key_header VARCHAR(128)")) + await conn.execute( + text("UPDATE yuxi_schema_migrations SET version = 2, applied_at = CURRENT_TIMESTAMP WHERE domain = 'business'") + ) + + +async def main() -> None: + """使用当前部署连接,显式执行一次结构升级。""" + manager = PostgresManager() + manager.initialize() + try: + await upgrade_mcp_personal_credentials(manager) + finally: + await manager.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/yuxi/modules/extensions/mcp/models.py b/backend/yuxi/modules/extensions/mcp/models.py index fa0c8f8fb..9c0f543f1 100644 --- a/backend/yuxi/modules/extensions/mcp/models.py +++ b/backend/yuxi/modules/extensions/mcp/models.py @@ -36,6 +36,7 @@ class MCPServer(Base): sse_read_timeout = Column(Integer, nullable=True, comment="SSE 读取超时(秒)") auth_mode = Column(String(20), nullable=False, default="headers", server_default="headers") + api_key_header = Column(String(128), nullable=True) oauth_config = Column(JSON, nullable=True) oauth_secret = Column(Text, nullable=True) oauth_registration = Column(Text, nullable=True) @@ -64,6 +65,7 @@ def to_dict(self) -> dict[str, Any]: "url": self.url, "headers": self.headers or {}, "auth_mode": self.auth_mode or "headers", + "api_key_header": self.api_key_header, "oauth_config": self.oauth_config, "oauth_client_secret_set": bool(self.oauth_secret), "timeout": self.timeout, diff --git a/backend/yuxi/modules/extensions/mcp/oauth_config.py b/backend/yuxi/modules/extensions/mcp/oauth_config.py index aefdb881b..62ff07b74 100644 --- a/backend/yuxi/modules/extensions/mcp/oauth_config.py +++ b/backend/yuxi/modules/extensions/mcp/oauth_config.py @@ -3,6 +3,7 @@ import hashlib import json import os +import re from typing import Literal from urllib.parse import urlsplit @@ -13,6 +14,9 @@ from yuxi.infrastructure.runtime_settings import get_public_url from yuxi.shared.errors import StateConflictError +STATIC_AUTH_MODES = {"bearer", "api_key"} +PERSONAL_AUTH_MODES = STATIC_AUTH_MODES | {"oauth"} + class MCPOAuthConfig(BaseModel): """由超管固定授权信任根,远端发现不能扩大允许来源。""" @@ -35,21 +39,49 @@ def validate_registration(self): return self -def configure_mcp_auth(server, auth_mode: str, oauth_config: dict | None) -> None: - """管理员配置只保存脱敏元数据,客户端 secret 单独加密。""" +def configure_mcp_auth(server, auth_mode: str, oauth_config: dict | None, api_key_header: str | None = None) -> None: + """声明认证方式,个人秘密不进入共享请求头。""" if auth_mode == "delegation": raise ValueError("平台委托 MCP 由内置清单同步,不能通过管理接口配置") - if auth_mode not in {"headers", "oauth"}: - raise ValueError("MCP 认证方式必须为 headers 或 oauth") + if auth_mode not in {"none", "headers"} | PERSONAL_AUTH_MODES: + raise ValueError("MCP 认证方式无效") + if server.auth_mode == "headers" and auth_mode in PERSONAL_AUTH_MODES and server.headers: + raise ValueError("切换个人认证前请清空共享请求头") + if auth_mode == "api_key": + api_key_header = api_key_header if api_key_header is not None else server.api_key_header + if ( + not api_key_header + or len(api_key_header) > 128 + or not re.fullmatch(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", api_key_header) + or api_key_header.lower() + in { + "host", + "content-length", + "transfer-encoding", + "connection", + "content-type", + "accept", + "mcp-session-id", + "mcp-protocol-version", + } + ): + raise ValueError("API Key 请求头名称无效") + else: + api_key_header = None server.auth_mode = auth_mode - if auth_mode == "headers": + server.api_key_header = api_key_header + if auth_mode in PERSONAL_AUTH_MODES: + validate_url(server.url) + reserved_headers = {"authorization"} + if api_key_header: + reserved_headers.add(api_key_header.lower()) + if auth_mode != "headers" and reserved_headers.intersection(key.lower() for key in (server.headers or {})): + raise ValueError("公共请求头不能包含所选方式的认证头") + if auth_mode != "oauth": server.oauth_config, server.oauth_secret, server.oauth_registration = None, None, None return - validate_url(server.url) if server.transport != "streamable_http": raise ValueError("个人 OAuth 仅支持 Streamable HTTP") - if any(key.lower() == "authorization" for key in (server.headers or {})): - raise ValueError("个人 OAuth 不允许共享 Authorization 请求头") config = MCPOAuthConfig.model_validate(oauth_config if oauth_config is not None else server.oauth_config) previous = server.oauth_config or {} if any(previous.get(field) != getattr(config, field) for field in ("issuer_url", "client_id", "registration")): @@ -99,7 +131,7 @@ def cipher() -> Fernet: try: return get_encryption_cipher() except ValueError as exc: - raise StateConflictError("MCP 个人授权未配置加密密钥,请联系超级管理员", code="mcp_oauth_unconfigured") from exc + raise StateConflictError("MCP 个人凭证未配置加密密钥,请联系超级管理员", code="mcp_oauth_unconfigured") from exc def encrypt(value: dict) -> str: @@ -117,5 +149,15 @@ def decrypt(value: str) -> dict: def config_revision(server) -> str: """服务地址、请求头或授权配置改变后不能继续使用旧凭据。""" - payload = [server.url, server.transport, server.headers, server.auth_mode, server.oauth_config, server.oauth_secret] + payload = [ + server.url, + server.transport, + server.headers, + server.auth_mode, + server.oauth_config, + server.oauth_secret, + ] + # 空的新字段保持旧 OAuth 修订不变,升级不要求既有用户重新授权。 + if server.api_key_header is not None: + payload.append(server.api_key_header) return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest() diff --git a/backend/yuxi/modules/extensions/mcp/oauth_runtime.py b/backend/yuxi/modules/extensions/mcp/oauth_runtime.py index ed9f71d80..4ee128f62 100644 --- a/backend/yuxi/modules/extensions/mcp/oauth_runtime.py +++ b/backend/yuxi/modules/extensions/mcp/oauth_runtime.py @@ -6,7 +6,7 @@ from langchain_mcp_adapters.client import MultiServerMCPClient from yuxi.infrastructure.postgres.manager import pg_manager -from yuxi.modules.extensions.mcp.oauth_config import config_revision +from yuxi.modules.extensions.mcp.oauth_config import STATIC_AUTH_MODES, config_revision from yuxi.modules.extensions.mcp.oauth_service import MCPOAuthService from yuxi.modules.extensions.mcp.runtime import to_camel_case from yuxi.shared.errors import StateConflictError @@ -17,29 +17,42 @@ class PersonalMCPAuth(httpx.Auth): requires_request_body = True - def __init__(self, slug, uid, revision): + def __init__(self, slug, uid, revision, *, auth_mode="oauth", url=None): + """绑定服务来源与个人身份,每次请求重新取得凭证。""" self.slug, self.uid, self.revision = slug, uid, revision + self.auth_mode = auth_mode + endpoint = httpx.URL(url) if url else None + self.origin = (endpoint.scheme, endpoint.host, endpoint.port) if endpoint else None async def async_auth_flow(self, request): """实际工具调用再次检查开关,已断开的旧工具对象也无法使用。""" + if self.origin is not None and (request.url.scheme, request.url.host, request.url.port) != self.origin: + raise StateConflictError("MCP 请求来源与配置不一致") tool_name = None if request.method == "POST" and request.content: payload = json.loads(request.content) if payload.get("method") == "tools/call": tool_name = payload.get("params", {}).get("name") async with pg_manager.get_async_session_context() as db: - token = await MCPOAuthService(db).access_token( - self.slug, - self.uid, - tool_name=tool_name, - expected_revision=self.revision, - ) - request.headers["Authorization"] = "Bearer " + token + service = MCPOAuthService(db) + if self.auth_mode in STATIC_AUTH_MODES: + header, value, generation = await service.static_credentials( + self.slug, self.uid, tool_name=tool_name, expected_revision=self.revision + ) + else: + token = await service.access_token(self.slug, self.uid, tool_name=tool_name, expected_revision=self.revision) + header, value = "Authorization", "Bearer " + token + request.headers[header] = value response = yield request if response.status_code == 401: async with pg_manager.get_async_session_context() as db: - await MCPOAuthService(db).reject_access_token(self.slug, self.uid, token) + if self.auth_mode in STATIC_AUTH_MODES: + await MCPOAuthService(db).reject_credentials(self.slug, self.uid, generation) + else: + await MCPOAuthService(db).reject_access_token(self.slug, self.uid, token) raise StateConflictError("MCP 拒绝了个人授权,请重新连接", code="mcp_reconnect_required") + if response.status_code == 403: + raise StateConflictError("MCP 服务方权限不足,请检查个人账号权限", code="mcp_remote_forbidden") async def personal_mcp_tools(server, uid, *, include_disabled=False): @@ -48,7 +61,7 @@ async def personal_mcp_tools(server, uid, *, include_disabled=False): raise StateConflictError("MCP 个人授权缺少运行用户身份") config = server.to_mcp_config() config.pop("disabled_tools", None) - config["auth"] = PersonalMCPAuth(server.slug, uid, config_revision(server)) + config["auth"] = PersonalMCPAuth(server.slug, uid, config_revision(server), auth_mode=server.auth_mode, url=server.url) tools = await MultiServerMCPClient({server.slug: config}).get_tools() for tool in tools: if tool.metadata is None: diff --git a/backend/yuxi/modules/extensions/mcp/oauth_service.py b/backend/yuxi/modules/extensions/mcp/oauth_service.py index 64ebf5e04..83bd4135a 100644 --- a/backend/yuxi/modules/extensions/mcp/oauth_service.py +++ b/backend/yuxi/modules/extensions/mcp/oauth_service.py @@ -7,7 +7,17 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from yuxi.modules.extensions.mcp.models import MCPOAuthAttempt -from yuxi.modules.extensions.mcp.oauth_config import MCPOAuthConfig, cipher, config_revision, decrypt, encrypt, origin, redirect_uri +from yuxi.modules.extensions.mcp.oauth_config import ( + PERSONAL_AUTH_MODES, + STATIC_AUTH_MODES, + MCPOAuthConfig, + cipher, + config_revision, + decrypt, + encrypt, + origin, + redirect_uri, +) from yuxi.modules.extensions.mcp.oauth_protocol import ( OAuthTokenRejected, discover, @@ -43,7 +53,7 @@ def fingerprint(value): def connection_status(server, connection): """状态投影只读取本人的授权事实,不暴露凭据。""" - if server.auth_mode != "oauth": + if server.auth_mode not in PERSONAL_AUTH_MODES: return "not_required" if not connection: return "disconnected" @@ -53,11 +63,52 @@ def connection_status(server, connection): class MCPOAuthService: - """连接行的世代与行锁拥有发布、撤销和令牌轮换顺序。""" + """个人连接复用行锁与世代;OAuth 协议只在授权和刷新时执行。""" def __init__(self, db): self.db = db + async def save_credentials(self, slug, user_id, secret, revision): + """按表单修订发布本人的静态凭证,与配置变更串行提交。""" + server = await get_mcp_server(self.db, slug, lock=True) + if not server or not server.enabled: + raise ResourceNotFoundError("MCP 服务不存在或已停用") + if server.auth_mode not in STATIC_AUTH_MODES or config_revision(server) != revision: + raise StateConflictError("MCP 配置已改变,请重新打开凭证表单") + await active_mcp_user(self.db, user_id=user_id) + connection = await mcp_connection(self.db, server.id, user_id, lock=True, create=True) + connection.generation += 1 + connection.credentials = encrypt({"secret": secret}) + connection.config_revision = revision + connection.status, connection.expires_at = "connected", None + await self.db.commit() + return connection.status + + async def static_credentials(self, slug, uid, *, tool_name=None, expected_revision=None): + """每次出站回读本人的静态认证头及其连接世代。""" + user, server = await self.require_runtime_policy(slug, uid, None, expected_revision) + connection = await mcp_connection(self.db, server.id, user.id, lock=True) + _, server = await self.require_runtime_policy(slug, uid, tool_name, expected_revision) + if server.auth_mode not in STATIC_AUTH_MODES or not connection or connection_status(server, connection) != "connected": + raise StateConflictError("请在扩展 → MCP 配置个人凭证", code="mcp_reconnect_required") + secret = decrypt(connection.credentials)["secret"] + header = "Authorization" if server.auth_mode == "bearer" else server.api_key_header + value = "Bearer " + secret if server.auth_mode == "bearer" else secret + generation = connection.generation + await self.db.commit() + return header, value, generation + + async def reject_credentials(self, slug, uid, generation): + """迟到的旧请求不能撤销用户后来替换的静态凭证。""" + user = await active_mcp_user(self.db, uid=uid) + server = await get_mcp_server(self.db, slug) + if not server: + return + connection = await mcp_connection(self.db, server.id, user.id, lock=True) + if connection and connection.generation == generation and connection.status == "connected": + connection.status, connection.credentials, connection.expires_at = "reconnect_required", None, None + await self.db.commit() + async def start(self, slug, user_id, browser): """在远端注册后创建限时事务,浏览器回调不依赖前端 JWT。""" cipher() @@ -194,6 +245,8 @@ async def disconnect(self, slug, user_id): async def access_token(self, slug, uid, *, tool_name=None, expected_revision=None): """每次出站前读取有效用户与授权,锁内刷新并原子保存轮换令牌。""" user, server = await self.require_runtime_policy(slug, uid, None, expected_revision) + if server.auth_mode != "oauth": + raise StateConflictError("此 MCP 不使用 OAuth") connection = await mcp_connection(self.db, server.id, user.id, lock=True) # 等待另一个 worker 刷新时策略可能改变;获锁后重新回读。 _, server = await self.require_runtime_policy(slug, uid, tool_name, expected_revision) @@ -249,7 +302,7 @@ async def require_runtime_policy(self, slug, uid, tool_name, expected_revision): user, server = policy if not server or not server.enabled: raise ResourceNotFoundError("MCP 服务不存在或已停用") - if server.auth_mode != "oauth": + if server.auth_mode not in PERSONAL_AUTH_MODES: raise StateConflictError("此 MCP 不需要个人授权") if expected_revision is not None and config_revision(server) != expected_revision: raise StateConflictError("MCP 配置已改变,请重新运行", code="mcp_reconnect_required") diff --git a/backend/yuxi/modules/extensions/mcp/repository.py b/backend/yuxi/modules/extensions/mcp/repository.py index ef02459d9..0cfe82128 100644 --- a/backend/yuxi/modules/extensions/mcp/repository.py +++ b/backend/yuxi/modules/extensions/mcp/repository.py @@ -6,9 +6,12 @@ from yuxi.modules.extensions.mcp.models import MCPServer -async def get_mcp_server(db: AsyncSession, slug: str) -> MCPServer | None: - """Get single server configuration by slug.""" - result = await db.execute(select(MCPServer).filter(MCPServer.slug == slug).execution_options(populate_existing=True)) +async def get_mcp_server(db: AsyncSession, slug: str, *, lock: bool = False) -> MCPServer | None: + """按稳定标识回读配置,凭证发布与配置变更可持有同一服务锁。""" + query = select(MCPServer).filter(MCPServer.slug == slug).execution_options(populate_existing=True) + if lock: + query = query.with_for_update() + result = await db.execute(query) return result.scalar_one_or_none() @@ -175,13 +178,18 @@ async def runtime_mcp_policy(db, slug, uid): async def invalidate_mcp_connections(db, server_id): - """配置变更推进所有个人连接世代,旧配置恢复也不能复活授权。""" - from sqlalchemy import update + """配置变更推进连接世代并清空凭证,保留用户主动清除的状态。""" + from sqlalchemy import case, update from yuxi.modules.extensions.mcp.models import MCPConnection await db.execute( update(MCPConnection) .where(MCPConnection.server_id == server_id) - .values(generation=MCPConnection.generation + 1, status="reconnect_required", credentials=None, expires_at=None) + .values( + generation=MCPConnection.generation + 1, + status=case((MCPConnection.status == "disconnected", "disconnected"), else_="reconnect_required"), + credentials=None, + expires_at=None, + ) ) diff --git a/backend/yuxi/modules/extensions/mcp/runtime.py b/backend/yuxi/modules/extensions/mcp/runtime.py index da97a61cb..bae843cf3 100644 --- a/backend/yuxi/modules/extensions/mcp/runtime.py +++ b/backend/yuxi/modules/extensions/mcp/runtime.py @@ -94,8 +94,8 @@ async def get_mcp_tools( return [] validate_remote_transport(server_config) try: - client_config = {key: value for key, value in server_config.items() if key != "disabled_tools"} - client_config.setdefault("auth", SharedMCPAuth(server_slug, client_config)) + client_config = {key: value for key, value in server_config.items() if key not in {"disabled_tools", "auth_mode"}} + client_config.setdefault("auth", SharedMCPAuth(server_slug, client_config, auth_mode=server_config.get("auth_mode", "headers"))) client = await get_mcp_client({server_slug: client_config}) tools = cast(list[Any], await client.get_tools()) for tool in tools: diff --git a/backend/yuxi/modules/extensions/mcp/service.py b/backend/yuxi/modules/extensions/mcp/service.py index e7b19b717..8aeb9abdd 100644 --- a/backend/yuxi/modules/extensions/mcp/service.py +++ b/backend/yuxi/modules/extensions/mcp/service.py @@ -10,7 +10,7 @@ from yuxi.modules.extensions.mcp.builtin import BUILTIN_MCP_DEFAULTS, BUILTIN_MCP_MANIFEST, builtin_mcp_available from yuxi.modules.extensions.mcp.config import RemoteMCPConfig, normalize_mcp_manifest_entry from yuxi.modules.extensions.mcp.models import MCPServer -from yuxi.modules.extensions.mcp.oauth_config import config_revision, configure_mcp_auth +from yuxi.modules.extensions.mcp.oauth_config import PERSONAL_AUTH_MODES, config_revision, configure_mcp_auth from yuxi.modules.extensions.mcp.oauth_runtime import personal_mcp_tools from yuxi.modules.extensions.mcp.repository import ( delete_mcp_server_row, @@ -141,11 +141,11 @@ async def get_mcp_tools( else: servers = await get_enabled_mcp_servers(names=[server_slug]) server = servers[0] if servers else None - if server is not None and getattr(server, "auth_mode", "headers") != "headers": + if server is not None and server.auth_mode not in {"headers", "none"}: raise ValueError("个人授权或平台委托的 MCP 必须经可信用户身份加载") config = _to_runtime_mcp_config(server) if server is not None else None - if config and config.get("auth_mode") == "oauth": - raise ValueError("个人 OAuth MCP 必须经可信用户身份加载") + if config and config.get("auth_mode") in PERSONAL_AUTH_MODES: + raise ValueError("个人 MCP 必须经可信用户身份加载") if config and config.get("auth_mode") == "delegation": raise ValueError("平台委托 MCP 由 Agent 运行时按当前用户装配,不支持共享连接加载") if config and "auth" in config: @@ -171,6 +171,7 @@ async def create_mcp_server( created_by: str = None, auth_mode: str = "headers", oauth_config: dict | None = None, + api_key_header: str | None = None, ) -> MCPServer: """创建并独立发布远程 MCP 配置。""" if slug in BUILTIN_MCP_SERVERS: @@ -198,7 +199,7 @@ async def create_mcp_server( created_by=created_by, updated_by=created_by, ) - configure_mcp_auth(server, auth_mode, oauth_config) + configure_mcp_auth(server, auth_mode, oauth_config, api_key_header) await save_mcp_server(db, server, new=True) await db.commit() await db.refresh(server) @@ -219,9 +220,10 @@ async def update_mcp_server( updated_by: str = None, auth_mode: str | None = None, oauth_config: dict | None = None, + api_key_header: str | None = None, ) -> MCPServer: - """Update server configuration.""" - server = await get_mcp_server(db, slug) + """在服务锁内更新配置,并使旧修订的个人连接失效。""" + server = await get_mcp_server(db, slug, lock=True) if not server: raise MCPServerNotFoundError(f"Server '{slug}' does not exist") if is_builtin_mcp_server(server): @@ -246,7 +248,7 @@ async def update_mcp_server( if updated_by is not None: server.updated_by = updated_by - configure_mcp_auth(server, auth_mode or server.auth_mode or "headers", oauth_config) + configure_mcp_auth(server, auth_mode or server.auth_mode or "headers", oauth_config, api_key_header) if config_revision(server) != previous_revision: await invalidate_mcp_connections(db, server.id) await db.commit() @@ -368,7 +370,7 @@ async def resolve_runtime_mcp_tools( async def load(server): """隔离单服务发现故障,日志和提示只包含安全原因。""" try: - if server.auth_mode == "oauth": + if server.auth_mode in PERSONAL_AUTH_MODES: return await personal_mcp_tools(server, uid, include_disabled=True) config = _to_runtime_mcp_config(server) if server.auth_mode == "delegation": @@ -411,6 +413,8 @@ def runtime_mcp_failure(exc: Exception) -> tuple[str, bool]: if isinstance(exc, KnowledgeMCPRuntimeError): return "运行上下文缺少可信知识库范围,能力未装配", True if isinstance(exc, StateConflictError): + if exc.code == "mcp_remote_forbidden": + return "MCP 服务方权限不足,请检查个人账号权限", False if exc.code == "mcp_oauth_unconfigured": return "部署未配置有效的 MCP 凭据加密密钥,请联系管理员", True if exc.code == "mcp_reconnect_required": @@ -448,12 +452,12 @@ async def personal_connection_states(db: AsyncSession, servers: list[MCPServer], async def get_enabled_mcp_tools(server_slug: str, *, uid: str | None = None) -> list: - """单服务执行入口仅装配共享 headers 与个人 OAuth;委托经运行装配。""" + """单服务按当前用户装配个人凭证,无认证与共享连接沿用现有路径。""" servers = await get_enabled_mcp_servers(names=[server_slug]) if not servers: return [] server = servers[0] - if server.auth_mode == "oauth": + if server.auth_mode in PERSONAL_AUTH_MODES: return await personal_mcp_tools(server, uid) if server.auth_mode == "delegation": raise ValueError("平台委托 MCP 由 Agent 运行时按当前用户装配,不支持单服务直接加载") @@ -476,7 +480,7 @@ async def inspect_mcp_server_tools(server: MCPServer, *, uid: str | None = None) """管理端严格建连,包括停用服务;不吞异常、不修改运行状态。""" if not builtin_mcp_available(server.slug): raise ValueError("MCP 服务在当前部署不可用") - if getattr(server, "auth_mode", "headers") == "oauth": + if server.auth_mode in PERSONAL_AUTH_MODES: return await personal_mcp_tools(server, uid, include_disabled=True) config = _to_runtime_mcp_config(server) config["auth"] = None @@ -504,8 +508,8 @@ def _to_runtime_mcp_config(server: MCPServer) -> dict[str, Any]: """生成运行时 MCP 配置,内置连接字段始终以代码定义为准。""" if not is_builtin_mcp_server(server): config = server.to_mcp_config() - if getattr(server, "auth_mode", "headers") == "oauth": - config["auth_mode"] = "oauth" + if server.auth_mode != "headers": + config["auth_mode"] = server.auth_mode return config builtin = normalize_mcp_manifest_entry(server.slug, BUILTIN_MCP_SERVERS[server.slug]) diff --git a/docs/agents/mcp-integration.md b/docs/agents/mcp-integration.md index 9617839c7..810ab9413 100644 --- a/docs/agents/mcp-integration.md +++ b/docs/agents/mcp-integration.md @@ -1,23 +1,27 @@ # 集成 MCP -MCP(Model Context Protocol)让智能体调用外部服务提供的工具。超级管理员在“扩展 → MCP”中添加远程服务器;所有登录用户可以连接自己的 OAuth 账号。Agent 可直接选择服务器,Skill 也可声明激活后加载的依赖。 +MCP(Model Context Protocol)让智能体调用外部服务提供的工具。超级管理员在“扩展 → MCP”中维护服务目录;所有登录用户可以填写自己的 Token、API Key 或连接 OAuth 账号。Agent 可直接选择服务器,Skill 也可声明激活后加载的依赖。 -点击 MCP 卡片打开简介与授权状态。个人 OAuth 服务在授权区域连接、重连或断开;超级管理员在弹窗底部点击“管理 MCP”维护全局配置。普通用户只操作自己的授权。未连接的个人 MCP 不影响普通聊天;运行时暂不提供相关工具,连接状态在扩展页面查看。连接后重新发起请求即可使用。卡片统一使用默认图标,颜色按稳定标识映射。 +点击 MCP 卡片打开简介与个人凭证。用户按管理员选择的方式填写凭证或完成授权;超级管理员在弹窗底部点击“管理 MCP”维护服务配置。未配置个人凭证不影响普通聊天,运行时暂不提供相关工具;配置后重新发起请求即可使用。卡片统一使用默认图标,颜色按稳定标识映射。 ## 选择接入方式 -| 服务的认证要求 | Yuxi 配置 | 谁完成认证 | +“接入方式”按凭证归属分组。个人方式由每位用户填写凭证或授权;共享方式由管理员填写整组认证请求头,所有用户共用其中的凭证。共享请求头直接显示在表单中,支持 Bearer Token 或 API Key 等服务文档要求的格式。 + +| 服务的认证要求 | 管理员填写 | 用户填写或操作 | | --- | --- | --- | -| 无认证或共享 API Key | `auth_mode=headers`,按需填写 headers | 超管配置,全体使用者共享服务身份 | -| 预注册 OAuth 客户端 | `auth_mode=oauth`、`registration=static`,填写 Client ID/Secret | 超管注册客户端,每名用户连接自己的账号 | -| 动态注册 OAuth | `auth_mode=oauth`、`registration=dynamic` | 平台持久化注册结果,每名用户个人授权 | -| 自建企业个人业务,如考勤 | 每名用户 OAuth 授权 | 资源服务按已验证 token 的 issuer/subject 隔离数据 | +| 无需认证 | `auth_mode=none`,无需凭证 | 无 | +| 个人 Bearer Token | `auth_mode=bearer`,固定使用 Authorization 请求头 | 只填原始 Token,系统添加 `Bearer ` 前缀 | +| 个人 API Key | `auth_mode=api_key`,填写 `api_key_header`,例如 `X-API-Key` | 只填原始 Key,系统原样发送到指定请求头 | +| 预注册 OAuth 客户端 | `auth_mode=oauth`、`registration=static`,填写 Issuer、Client ID,按服务方要求填写 Secret 和 scope | 点击授权,在服务方登录并同意 | +| 动态注册 OAuth | `auth_mode=oauth`、`registration=dynamic`,填写 Issuer 和 scope | 点击授权,客户端注册由后端完成 | +| 共享请求头(所有用户共用) | `auth_mode=headers`,直接填写共享请求头 JSON | 无;所有使用者共用管理员配置的服务身份 | 平台登录和 MCP 资源授权分别签发凭据;部署密钥、状态与调用隔离的实现见 [MCP 授权与运行身份](../mechanisms/mcp.md)。 平台自带 Knowledge 是内置 `knowledge` MCP server(见下节),由内置 `knowledge-base` Skill 的 `mcp_dependencies` 门控装配,每次调用使用内部短期用户委托,无需按本页添加服务器或连接 OAuth,管理页也不能为其配置共享凭据。对外客户端仍使用 `knowledge` API Key,见[知识库 MCP 接入](../advanced/knowledge-mcp.md);内部身份与会话范围见[知识库机制](../mechanisms/knowledge-base.md#agent-如何看到知识库)。 -接入前需确认服务支持发现协议、PKCE、回调地址和真实工具调用。 +选择个人 OAuth 前需确认服务支持发现协议、PKCE、回调地址和真实工具调用。个人 API Key 首期支持一个请求头,不支持查询参数、请求体或自定义前缀;Basic 用户名密码不在配置范围内。 ## 支持的传输方式 @@ -30,14 +34,15 @@ MCP(Model Context Protocol)让智能体调用外部服务提供的工具。 ## 添加远程 MCP -在“扩展 → MCP”点击“添加 MCP”,填写稳定标识、名称、传输方式和 URL。例如: +在“扩展 → MCP”点击“新增服务”,填写稳定标识、名称、传输方式、URL,并显式选择接入方式。例如: ```json { "slug": "custom-remote-mcp", "name": "Example MCP", "transport": "streamable_http", - "url": "https://example.com/mcp" + "url": "https://example.com/mcp", + "auth_mode": "none" } ``` @@ -53,14 +58,25 @@ Content-Type: application/json "name": "Example MCP", "transport": "streamable_http", "url": "https://example.com/mcp", - "description": "提供示例查询工具" + "description": "提供示例查询工具", + "auth_mode": "none" } ``` -需要认证的远程服务可以配置 HTTP headers、连接超时和 SSE 读取超时。凭证会随着连接请求发送,请只配置必要的 header,并把管理接口限制在可信的管理员范围。 +个人认证方式的公共 headers 只配置非敏感协议字段,不能再包含 `Authorization` 或个人 API Key 的认证头,大小写冲突会被拒绝。个人秘密由用户在卡片中单独填写;管理员只为明确需要共享服务身份的连接填写共享凭据。连接超时和 SSE 读取超时仍在高级连接选项中配置。 新增服务器立即启用。[创建智能体](./agents-config.md#创建智能体)的高级配置提供相同的独立创建入口,成功后选入 Context;取消或创建 Agent 失败时服务器仍保留。可以在 MCP 管理页点击“测试连接”确认工具发现结果,也可关闭服务器状态;状态关闭时记录仍保留,但不会进入运行时。 +## 配置个人 Token 或 API Key + +管理员选择“每名用户填写 Bearer Token”或“每名用户填写 API Key”。API Key 方式还需指定认证请求头名称;用户不填写服务地址或任意请求头。个人静态凭证支持 SSE 与 Streamable HTTP,生产环境要求 HTTPS。 + +用户打开 MCP 卡片,填写服务方提供的原始值并保存。页面显示“已配置”;点击“测试连接”才执行本人的工具发现,零个工具也是合法结果。保存不自动测试,测试不执行业务工具。已保存秘密不回填;右侧按钮组的下拉菜单提供替换和清除,替换时打开空输入框,取消保留原值。Token 不填写 `Bearer ` 前缀,API Key 不额外添加前缀。 + +个人凭证加密保存在后端,缺失、断开或失效时不回退共享身份。管理员测试个人服务也使用本人的凭证。服务停用后,有本人连接的卡片保留清除入口,禁止替换与测试;清除后该停用卡片隐藏。修改地址、传输、认证方式或认证头要求所有用户重新配置;仅启停服务保留凭证。HTTP 401 要求更新凭证,403 不自动清除。 + +已有共享服务可以由管理员显式切换。切换表单提示并清空原共享请求头,不把共享秘密复制到个人连接;需要的非敏感协议头可以重新填写。静态凭证不自动刷新,用户从服务方取得新值后替换。 + ## 个人账号授权 超级管理员添加或编辑 MCP 时选择“每名用户 OAuth 授权”,填写授权服务器 Issuer、注册方式和授权范围。预注册方式填写 Client ID 和 Client Secret;修改时留空 Secret 保留同一客户端已有凭据。更换 Issuer 或 Client ID 会清除旧 Secret,必须重新配置。个人 OAuth 使用 Streamable HTTP,请求头不能同时包含共享 Authorization。 @@ -135,11 +151,11 @@ Yuxi 的所有个人 OAuth MCP 共用发现、客户端注册、授权码、PKCE 共享 Skill 的 `mcp_dependencies` 声明所需服务器。Skill 激活后按需加载这些服务器的工具;预加载 Skill 从首轮加载,均无需在 Agent 的 MCP 字段重复选择。服务器必须由管理员启用,禁用的服务器不会提供工具。 -MCP 配置从 PostgreSQL 读取。共享 headers 和个人 OAuth 工具都在每次 Run 按需发现一次目录并在运行内复用,新 Run 重新发现。个人 OAuth 每次出站重新读取当前用户的有效凭据。修改地址或授权配置后需要重新连接;停用服务、停用工具和断开个人连接都会阻止旧工具对象的后续调用。 +MCP 配置从 PostgreSQL 读取。共享 headers 和个人凭证工具都在每次 Run 按需发现一次目录并在运行内复用,新 Run 重新发现。个人凭证每次出站重新读取当前用户的有效值。修改地址或授权配置后需要重新连接;停用服务、停用工具和断开个人连接都会阻止旧工具对象的后续调用。 ## 部署个人 OAuth -API 与 worker 从同一部署环境读取以下变量,配置后重新创建对应容器: +API 与 worker 从同一部署环境读取以下变量,配置后重新创建对应容器;静态个人凭证也使用相同加密密钥,OAuth 另需公开回调来源: | 变量 | 用途 | | --- | --- | @@ -150,6 +166,17 @@ API 与 worker 从同一部署环境读取以下变量,配置后重新创建 OAuth 元数据只能使用超管配置的 MCP 来源与授权服务器来源,跨来源重定向会失败。企业内网服务需要浏览器与 API/worker 都能访问准确地址。地址、授权服务器或客户端配置变化后,用户需要重连。数据库结构与新库初始化边界见 [系统架构](https://github.com/xerrors/Yuxi/blob/main/ARCHITECTURE.md)。 +## 已有部署升级个人凭证字段 + +业务 Schema v2 包含 `api_key_header`。已有 business v1 数据库在启动新版本 API/worker 前,显式执行幂等升级,再一并重启运行进程: + +```bash +docker compose exec api uv run --no-sync python -m yuxi.migrations.mcp_personal_credentials +docker compose restart api worker +``` + +升级只增加可空字段并登记业务 Schema v2,保留已有服务、共享请求头和个人 OAuth 凭证。空的新字段不改变旧 OAuth 配置修订。新库由 app-init 直接建立 v2;运行进程只检查版本,不自动修改结构。升级前保管数据库备份与部署加密密钥,回退旧进程版本需显式处理 Schema 版本兼容。 + ## 内置远程 MCP 内置 DeepWiki 使用 `deepwiki-official` 标识,通过 `https://mcp.deepwiki.com/mcp` 提供 Streamable HTTP 服务,无需认证即可查询公开 GitHub 仓库。详见 [DeepWiki 官方文档](https://docs.devin.ai/work-with-devin/deepwiki-mcp)。 @@ -184,7 +211,9 @@ BUILTIN_MCP_MANIFEST = { | `GET` | `/api/system/mcp-servers` | 查看服务器;普通用户只得到脱敏基础信息 | | `POST` / `PUT` | `/api/system/mcp-servers`、`/{slug}` | 添加或修改远程 MCP | | `PUT` | `/api/system/mcp-servers/{slug}/status` | 启用或停用服务器(对应页面上的添加/移除状态) | -| `POST` | `/api/system/mcp-servers/{slug}/test` | 测试连接并发现工具 | +| `PUT` | `/api/system/mcp-servers/{slug}/connection` | 用户保存或替换自己的 Token / API Key | +| `DELETE` | `/api/system/mcp-servers/{slug}/connection` | 用户清除自己的个人凭证或断开 OAuth | +| `POST` | `/api/system/mcp-servers/{slug}/test` | 使用本人凭证测试个人连接;其他方式仅超管可测试 | | `GET` | `/api/system/mcp-servers/{slug}/tools` | 查看工具 | | `PUT` | `/api/system/mcp-servers/{slug}/tools/{tool_name}/toggle` | 启用或禁用单个工具 | diff --git a/docs/develop-guides/decisions/implemented/2026-11-06-mcp-personal-credentials.md b/docs/develop-guides/decisions/implemented/2026-11-06-mcp-personal-credentials.md new file mode 100644 index 000000000..a36ab40c3 --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-11-06-mcp-personal-credentials.md @@ -0,0 +1,58 @@ +# MCP 个人静态凭证与鉴权表单 + +状态:implemented +类型:feature +Owner:backend/yuxi/modules/extensions/mcp/service.py + +## 问题 + +共享 HTTP 请求头只能表达一个服务身份,无法让使用同一 MCP 的用户分别提供自己的 API Key 或 Token。个人 OAuth 已有隔离连接,但不能替代只提供静态凭证的服务。 + +## 决策 + +### 实现方案 + +管理员维护服务目录,每名用户只配置自己的凭证。`auth_mode` 区分无认证、个人 Bearer、个人 API Key、个人 OAuth、管理员共享请求头和内置平台委托。Bearer 固定认证头及前缀;API Key 由管理员声明一个请求头名,用户只填原始值。填写矩阵及操作由 [MCP 集成](../../../agents/mcp-integration.md#选择接入方式)维护。 + +MCP 模块复用连接表、Fernet 加密、配置修订、世代和行锁;静态凭证不经过 OAuth 协议或自动刷新。保存接口以当前用户及表单修订发布密文,每次出站重新读取本人凭证。工具发现、真实调用与个人测试使用同一身份;未配置时不回退共享值。前端复用现有配置表单与卡片,保存只显示已配置,独立测试只执行初始化与发现。状态和权限由 [MCP 机制](../../../mechanisms/mcp.md)维护。 + +个人凭证组件统一拥有预览弹窗和详情页的标题、状态、表单与操作布局,预览弹窗保留基础高度并随内容展开。右侧按钮组以测试连接为主操作,替换和清除收进下拉菜单,测试成功直接更新主按钮;编辑替换草稿时只展示保存和取消,取消后保留已保存的连接。 + +管理员表单以“接入方式”区分个人与共享凭证,选项按提供凭证的人分组。共享请求头直接在主表单填写,并提供 Bearer 与 API Key 示例;个人方式的公共协议头与超时保留在高级连接选项中。 + +## 替代方案 + +仅使用共享凭据不能表达个人身份;强制 OAuth 无法接入仅有静态 Key 的服务。开放用户自建 URL 和任意请求头扩大了已确认范围。独立凭证库或通用凭证框架重复现有连接与加密事实,因此继续在 MCP 模块扩展两种静态方式。 + +## 后果 + +保留明确标注的管理员共享模式,迁移由管理员逐服务决定,不自动复制共享秘密。第一阶段支持一个原样发送的 API Key 请求头与固定 Bearer,不增加 Basic、查询参数、多字段凭证或前缀模板。 + +新增可空 `api_key_header` 字段,业务 Schema 为 v2。显式幂等升级保留已有数据;空字段保持旧 OAuth 修订不变。API/worker 仍只读校验 Schema,部署操作见 [已有部署升级](../../../agents/mcp-integration.md#已有部署升级个人凭证字段)。 + +修改服务配置要求重新连接,启停保留凭证。停用服务保留本人未清除连接的脱敏卡片,清除后隐藏;管理员也不能通过个人测试使用别人的凭证。清除约束后续出站检查,已经发出的请求不回滚。 + +## 验证 + +| 证据 | 观察结果 | 状态 | +| --- | --- | --- | +| PostgreSQL / HTTP integration,含 OAuth 回归、个人静态凭证、匿名访问、Schema 与初始化 | 86 项通过;两名用户实际返回不同身份,替换与清除后回读连接和远端请求,清除后改配置仍隐藏停用服务,升级重复执行保留旧值 | Passed | +| 后端 unit `test/unit -m "not slow"` | 3656 passed、127 skipped;包括旧 OAuth 修订的固定兼容值 | Passed | +| 宿主环境 config unit | 209 项通过,覆盖容器未挂载的仓库根配置 | Passed | +| 前端 unit | 1044 项通过,覆盖方式字段、保存请求与失败草稿 | Passed | +| 真实浏览器加载生产组件 | 固定 HTTP 响应验证保存失败重试、替换取消、独立测试、停用后清除、管理员方式切换;浅色、深色、失败状态截图,1024px / 768px 无溢出 | Passed | +| 个人 Bearer / API Key worker E2E | 两项通过;独立 MCP 核对秘密,模型只消费实际工具返回,最终输出与 result Run 一致,秘密未进入模型请求 | Passed | +| 腾讯会议真实服务探针 | 个人 API Key 配合官方版本协议头发现 33 项工具;预约结果按会议 ID 回读一致。其他供应商未覆盖 | Passed | + +关键命令: + +```bash +docker compose exec api uv run --no-sync --group test pytest test/unit -m "not slow" -q +docker compose exec api uv run --no-sync --group test pytest test/integration/services/test_mcp_oauth.py test/integration/services/test_schema_migration_version.py test/integration/services/test_app_initialization.py -q +docker compose exec api uv run --no-sync --group test pytest test/e2e/test_mcp_personal_credentials.py -q +uv run --project backend --no-sync --group test pytest backend/test/unit/config -q +(cd frontend && pnpm run test:unit) +playwright-cli -s=mcp-credentials run-code --filename=frontend/test/browser/mcpPersonalCredentials.js +``` + +worker E2E 直接登记一次性 HTTP MCP fixture,避免修改部署的实验来源白名单;它验证凭证与结果的跨进程链路,HTTPS 配置与来源拒绝由 integration 单独证明。完整 lint、build 与测试层级见 [测试规范](../../testing-guidelines.md)。 diff --git a/docs/mechanisms/mcp.md b/docs/mechanisms/mcp.md index 57e93a190..cce994a2e 100644 --- a/docs/mechanisms/mcp.md +++ b/docs/mechanisms/mcp.md @@ -6,13 +6,13 @@ Yuxi 作为远程 MCP 客户端消费外部工具;知识库对外提供的只读 MCP 属于另一条服务端链路,见 [知识库 MCP 接入](../advanced/knowledge-mcp.md)。认证中心由部署方现有的 OIDC 服务提供,Yuxi 通过标准协议访问。 -| 机制 | 共享 headers | 个人 OAuth | 平台委托 | -| --- | --- | --- | --- | -| 管理员配置 | 服务器、headers、启停和工具开关 | 服务器、Issuer、注册方式、scope、启停和工具开关 | 业务模块提供连接与 SDK Auth,管理员控制启停和工具开关 | -| 使用的身份 | 管理员配置的共享服务身份 | 本次运行实际用户的个人连接 | 业务模块从可信运行上下文取得当前用户与资源选择 | -| 支持的传输 | SSE、Streamable HTTP | Streamable HTTP | Streamable HTTP | -| 工具装配 | 每 Run 按需发现一次目录并复用工具 | 绑定可信 uid,按 Run 复用目录 | 绑定可信 uid,缓存完整目录并按当前开关过滤 | -| 调用前检查 | 复查服务、工具与连接配置 | 还复查用户、个人连接及配置修订 | 复用通用执行策略;业务模块提供请求凭据,服务端复核资源权限 | +| 机制 | 无认证或共享 headers | 个人 Bearer / API Key | 个人 OAuth | 平台委托 | +| --- | --- | --- | --- | --- | +| 管理员配置 | 服务器、公共或共享 headers、启停和工具开关 | 服务器、认证方式;API Key 另声明请求头名称 | 服务器、Issuer、注册方式、scope、启停和工具开关 | 业务模块提供连接与 SDK Auth,管理员控制开关 | +| 使用的身份 | 匿名或管理员配置的共享身份 | 本次运行用户保存的静态凭证 | 本次运行用户的 OAuth 连接 | 业务模块取得的可信用户与资源范围 | +| 支持的传输 | SSE、Streamable HTTP | SSE、Streamable HTTP | Streamable HTTP | Streamable HTTP | +| 工具装配 | 每 Run 按需发现一次目录并复用工具 | 绑定可信 uid,按 Run 复用目录 | 绑定可信 uid,按 Run 复用目录 | 绑定可信 uid,按当前开关过滤 | +| 调用前检查 | 复查服务、工具与连接配置 | 还回读本人凭证、连接世代及配置修订 | 还回读本人连接,按期限刷新 | 复用通用执行策略,服务端复核资源权限 | 平台超管负责全局配置,普通管理员和用户管理自己的连接;超管个人连接也仅属于本人。平台登录会话、OAuth 客户端注册和 MCP 个人 token 分别保存,平台登录 token 不转发给资源服务。 @@ -20,6 +20,10 @@ Yuxi 作为远程 MCP 客户端消费外部工具;知识库对外提供的只 ## 从连接到工具续答 +静态个人凭证通过 `PUT /api/system/mcp-servers/{slug}/connection` 保存,输入只包含秘密及表单修订。MCP service 在服务锁内复核方式、启停和修订,锁定本人的连接,推进世代并加密提交。保存只表达“已配置”;个人测试复用本人出站认证做初始化与工具发现,不执行业务工具,不另建验证状态。Bearer 固定发送 `Authorization: Bearer `;API Key 原样发送到服务声明的请求头,不进入 OAuth 发现或刷新。 + +OAuth 连接沿用以下流程: + 1. **发起连接**:浏览器以当前平台用户请求 API。API 发现 MCP 授权元数据,固定个人连接世代和限时事务,再返回 PKCE 授权地址。 2. **授权回调**:用户在授权服务器登录并授权,浏览器携带 code/state 回到 API。API 消费事务并兑换目标资源的 token。 3. **发布连接**:回调只暂存一次性结果;浏览器回到 MCP 页面,用当前 JWT 确认归属后,API 才在 PostgreSQL 发布本人的 `connected` 连接。 @@ -27,9 +31,9 @@ Yuxi 作为远程 MCP 客户端消费外部工具;知识库对外提供的只 连接入口绑定当前用户、浏览器、服务器配置修订、连接世代和准确回调,使用 Authorization Code + PKCE S256。回调消费五分钟授权事务并兑换 token,但不会立即发布可用连接;浏览器回到 MCP 页面后,当前 JWT 用户必须在一分钟内确认一次性结果。错误浏览器、过期或重放事务、期间切换账号均不能完成绑定。 -Agent 直接选择 MCP,或 Skill 激活后加载依赖,最终都以可信运行 uid 调用 `resolve_runtime_mcp_tools`。协作与定时运行继续使用各自已确定的执行用户;不从模型参数、Agent 创建者或全局管理员推导身份。MCP service 统一读取本次服务和本人连接;服务不存在、停用、未连接、修订失效或装配期间刷新被拒时排除该服务工具。除共享 headers 与个人 OAuth 外,平台托管的内置 server 可使用通用委托鉴权(`auth_mode=delegation`):业务模块根据可信运行上下文提供 SDK Auth,凭据由该模块在 HTTP 请求时生成,通用 MCP 不解释业务资源选择;无身份共享装配拒绝该模式,管理工具发现和连接测试使用当前查看者身份;内置 knowledge 是当前使用方,见[知识库机制](./knowledge-base.md#agent-如何看到知识库)。Agent 与 Skills 消费可用服务和工具;个人连接状态与操作入口由扩展页面展示。普通聊天继续执行,依赖该服务的 Skill 工具同时在模型与执行边界关闭。单服务发现或调用失败只关闭本 Run 中该 MCP 及相关 Skill 工具,普通聊天继续。Context 保留本 Run 的不可用服务供授权门控,图状态的 `warnings: list[RuntimeWarning]` 保存警告,每项包含 `code`、`source` 和安全提示 `message`;现有状态增量和 checkpoint 回读将警告送到状态面板,刷新后仍可见,每次检查整体替换列表,下一轮能力恢复时写入 `[]` 清空,不弹出通知。凭据密钥缺失和授权失效使用固定安全提示,网络或未知协议错误使用连接失败提示,服务日志记录异常类型和安全原因。管理页面连接测试仍明确返回失败,worker 不等待浏览器交互。 +Agent 直接选择 MCP,或 Skill 激活后加载依赖,最终都以可信运行 uid 调用 `resolve_runtime_mcp_tools`。协作与定时运行继续使用各自已确定的执行用户;不从模型参数、Agent 创建者或全局管理员推导身份。MCP service 统一读取本次服务和本人连接;服务不存在、停用、未连接、修订失效或装配期间刷新被拒时排除该服务工具。除无认证、共享 headers 与个人认证外,平台托管的内置 server 可使用通用委托鉴权(`auth_mode=delegation`):业务模块根据可信运行上下文提供 SDK Auth,凭据由该模块在 HTTP 请求时生成,通用 MCP 不解释业务资源选择;无身份共享装配拒绝该模式,管理工具发现和连接测试使用当前查看者身份;内置 knowledge 是当前使用方,见[知识库机制](./knowledge-base.md#agent-如何看到知识库)。Agent 与 Skills 消费可用服务和工具;个人连接状态与操作入口由扩展页面展示。普通聊天继续执行,依赖该服务的 Skill 工具同时在模型与执行边界关闭。单服务发现或调用失败只关闭本 Run 中该 MCP 及相关 Skill 工具,普通聊天继续。Context 保留本 Run 的不可用服务供授权门控,图状态的 `warnings: list[RuntimeWarning]` 保存警告,每项包含 `code`、`source` 和安全提示 `message`;现有状态增量和 checkpoint 回读将警告送到状态面板,刷新后仍可见,每次检查整体替换列表,下一轮能力恢复时写入 `[]` 清空,不弹出通知。凭据密钥缺失和授权失效使用固定安全提示,网络或未知协议错误使用连接失败提示,服务日志记录异常类型和安全原因。管理页面连接测试仍明确返回失败,worker 不等待浏览器交互。 -个人工具对象保存服务器 slug、运行 uid 和配置修订。HTTP 授权适配器在每次出站时回读数据库,`tools/call` 还复查具体工具开关;token 仅用于实际请求。共享工具也在出站前核对加载时的地址、传输和 headers,以及当前认证方式和开关,运行中的旧共享对象不能绕过个人 OAuth。Authorization 在执行前向 MCP service 批量读取本人可用服务与工具开关,嵌套 Skill 任意层的 MCP 不可用时关闭依赖工具;同一查询结果仅在当前调用内传递。Skills 只追加已激活 Skill 的 MCP 工具,本地工具从构图时的注册集合中筛选,嵌套连接依赖不触发未激活子 Skill 的工具发现。纯文本工具块在 Chat Completions 发送边界转换为字符串,原始结果和审计保持结构化;工具调用成功与真实模型最终续答成功需要分别验证,见 [工具文本协议决定](../develop-guides/decisions/implemented/2026-10-18-mcp-tool-text-wire.md)。 +个人工具对象保存服务器 slug、运行 uid 和配置修订。HTTP 授权适配器在每次出站时回读数据库,`tools/call` 还复查具体工具开关;token 仅用于实际请求。共享工具也在出站前核对加载时的地址、传输和 headers,以及当前认证方式和开关,运行中的旧共享对象不能绕过个人认证。Authorization 在执行前向 MCP service 批量读取本人可用服务与工具开关,嵌套 Skill 任意层的 MCP 不可用时关闭依赖工具;同一查询结果仅在当前调用内传递。Skills 只追加已激活 Skill 的 MCP 工具,本地工具从构图时的注册集合中筛选,嵌套连接依赖不触发未激活子 Skill 的工具发现。纯文本工具块在 Chat Completions 发送边界转换为字符串,原始结果和审计保持结构化;工具调用成功与真实模型最终续答成功需要分别验证,见 [工具文本协议决定](../develop-guides/decisions/implemented/2026-10-18-mcp-tool-text-wire.md)。 持久凭据使用基础设施提供的 `YUXI_ENCRYPTION_KEY`,MCP 只负责凭据载荷与领域错误;加密格式为 Fernet。`YUXI_PUBLIC_URL` 拥有部署公开来源,MCP 回调路径由代码固定,不从请求 Host 推导。 @@ -39,11 +43,13 @@ Agent 直接选择 MCP,或 Skill 激活后加载依赖,最终都以可信运 | 数据 | 拥有的事实 | | --- | --- | -| `mcp_servers` | 全局连接配置、认证方式、脱敏 OAuth 配置、加密客户端 secret 与注册结果 | +| `mcp_servers` | 全局连接配置、认证方式、API Key 请求头名称、脱敏 OAuth 配置、加密客户端 secret 与注册结果 | | `mcp_connections` | 每个服务器与用户唯一的连接、加密凭据、期限、配置修订及世代 | | `mcp_oauth_attempts` | 授权与确认阶段的短期事务;state 和浏览器只保存摘要,载荷加密 | -页面投影为 `not_required`(共享模式)、`disconnected`、`connected` 或 `reconnect_required`。切换 URL、传输、headers 或授权配置会改变修订;发布变更的同一事务推进所有个人连接的世代并清空凭据,配置改回原值也不能恢复旧授权或旧回调。名称等展示字段不参与修订,服务及工具启停在调用边界单独检查,停用保留个人连接。 +页面投影为 `not_required`(无认证、共享或平台委托)、`disconnected`、`connected` 或 `reconnect_required`。静态凭证的 `connected` 展示“已配置”,OAuth 展示“已授权”。切换 URL、传输、headers、API Key 请求头或授权配置会改变修订;发布变更的同一事务推进所有个人连接的世代并清空凭据,配置改回原值也不能恢复旧授权或旧回调。名称等展示字段不参与修订,服务及工具启停在调用边界单独检查,停用保留个人连接。 + +替换静态凭证只推进本人的连接世代;旧工具下次出站读取新值。远端 401 只撤销仍属于被拒绝世代的静态凭证,迟到的旧响应不能删除替换值;403 和网络错误不清除凭证。静态凭证不保存到期时间、不执行自动刷新。清除仅约束后续出站检查,已经发出的请求或已有工具结果不会回滚。 普通出站路径通过合并用户与服务查询,在连接行锁前后各检查策略,不执行远端 introspection。刷新仅在 token 临近过期时执行;远端调用结束后额外复查策略,轮换后的 refresh token 原子保存。等待行锁后和网络返回后复查当前策略,避免刷新期间撤权后继续出站。远端 401 只撤销实际被拒且仍为当前值的 token,旧请求不能覆盖新授权。 @@ -57,7 +63,7 @@ ARQ worker 在启动时及每分钟清理过期授权事务与待确认结果, ## 权限与账号隔离 -服务器配置修改在后端校验超级管理员,普通管理员也不能修改。连接、断开及回调确认只接受认证依赖取得的当前用户,不接受目标用户 ID。普通列表仅返回脱敏信息和本人状态;共享 `Authorization` 不能覆盖 OAuth 个人凭据。 +服务器配置修改在后端校验超级管理员,普通管理员也不能修改。连接、断开及回调确认只接受认证依赖取得的当前用户,不接受目标用户 ID。普通列表仅返回脱敏信息和本人状态;本人仍为 connected 或 reconnect_required 的停用个人服务保留清除入口,清除后隐藏。个人模式的公共 headers 不能包含 Authorization 或指定的 API Key 认证头,大小写冲突在配置边界拒绝。 平台身份拥有个人连接;资源身份由 MCP 根据 access token 验证并决定数据权限,两者无需对应同一企业员工。资源授权不会改变 Yuxi 平台角色。本地登录和企业登录均可发起个人 MCP 授权。 @@ -88,6 +94,8 @@ HTTP 200、工具列表和固定模型结果各自只证明对应层级。真实 | 每次 HTTP 取得个人凭据与工具装配 | [oauth_runtime.py](https://github.com/xerrors/Yuxi/blob/main/backend/yuxi/modules/extensions/mcp/oauth_runtime.py);远端工具发现由 [runtime.py](https://github.com/xerrors/Yuxi/blob/main/backend/yuxi/modules/extensions/mcp/runtime.py) 拥有 | | 连接按钮、回调确认和通用授权表单 | [前端 MCP 组件](https://github.com/xerrors/Yuxi/tree/main/frontend/src/modules/extensions/ui)及 [mcpOAuth.js](https://github.com/xerrors/Yuxi/blob/main/frontend/src/modules/extensions/model/mcpOAuth.js);[HTTP 路由](https://github.com/xerrors/Yuxi/blob/main/backend/yuxi/api/routers/extensions/mcp_oauth.py)只适配请求和认证上下文 | -持久结构由 [models.py](https://github.com/xerrors/Yuxi/blob/main/backend/yuxi/modules/extensions/mcp/models.py) 拥有,app-init 通过[当前基线初始化器](https://github.com/xerrors/Yuxi/blob/main/backend/yuxi/migrations/main.py)为新库建立完整结构,不保留历史增量脚本;已有数据库由部署侧先处理结构,见[版本 1 基线决定](../develop-guides/decisions/implemented/2026-10-19-schema-baseline-one.md)。修改协议、状态或权限时,以 [真实 HTTP/PostgreSQL 测试](https://github.com/xerrors/Yuxi/blob/main/backend/test/integration/services/test_mcp_oauth.py)证明隔离、重放拒绝、刷新轮换和断开竞争;[worker E2E](https://github.com/xerrors/Yuxi/blob/main/backend/test/e2e/test_mcp_personal_oauth_e2e.py)回读最终 Run 和审计。固定模型之外,真实浏览器加实际供应商模型仍是外部接入的独立验收;完整命令与测试层级见 [测试规范](../develop-guides/testing-guidelines.md)。 +持久结构由 [models.py](https://github.com/xerrors/Yuxi/blob/main/backend/yuxi/modules/extensions/mcp/models.py) 拥有,app-init 通过[当前基线初始化器](https://github.com/xerrors/Yuxi/blob/main/backend/yuxi/migrations/main.py)为新库建立完整结构,不保留历史增量脚本;已有数据库显式升级个人凭证字段,见[升级操作](../agents/mcp-integration.md#已有部署升级个人凭证字段)。修改协议、状态或权限时,以 [真实 HTTP/PostgreSQL 测试](https://github.com/xerrors/Yuxi/blob/main/backend/test/integration/services/test_mcp_oauth.py)证明隔离、重放拒绝、刷新轮换和断开竞争;[worker E2E](https://github.com/xerrors/Yuxi/blob/main/backend/test/e2e/test_mcp_personal_oauth_e2e.py)回读最终 Run 和审计。固定模型之外,真实浏览器加实际供应商模型仍是外部接入的独立验收;完整命令与测试层级见 [测试规范](../develop-guides/testing-guidelines.md)。 完整边界验收、收敛取舍与未验证范围见 [MCP 个人授权与运行边界](../develop-guides/decisions/implemented/2026-10-18-mcp-personal-oauth.md)。 + +个人静态凭证、无认证方式及现有 OAuth 的兼容取舍见 [个人凭证决定](../develop-guides/decisions/implemented/2026-11-06-mcp-personal-credentials.md)。 diff --git a/frontend/src/apis/mcp_api.js b/frontend/src/apis/mcp_api.js index f45ce7b24..64c1460bd 100644 --- a/frontend/src/apis/mcp_api.js +++ b/frontend/src/apis/mcp_api.js @@ -1,4 +1,4 @@ -import { apiPost, apiDelete, apiGet, apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base' +import { apiPost, apiPut, apiDelete, apiGet, apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base' /** * MCP 服务器管理 API 模块 @@ -106,6 +106,8 @@ export const toggleMcpServerTool = async (serverName, toolName) => { } export const mcpApi = { + saveCredentials: (slug, secret, configRevision) => apiPut(`${BASE_URL}/${encodeURIComponent(slug)}/connection`, { secret, config_revision: configRevision }), + testConnection: (slug) => apiPost(`${BASE_URL}/${encodeURIComponent(slug)}/test`, {}), connect: (slug) => apiPost(`${BASE_URL}/${encodeURIComponent(slug)}/connection`, {}), disconnect: (slug) => apiDelete(`${BASE_URL}/${encodeURIComponent(slug)}/connection`), complete: (result) => apiPost(`${BASE_URL}/oauth/complete`, { result }), diff --git a/frontend/src/modules/extensions/model/mcpOAuth.js b/frontend/src/modules/extensions/model/mcpOAuth.js index d72492c01..3ca10bb53 100644 --- a/frontend/src/modules/extensions/model/mcpOAuth.js +++ b/frontend/src/modules/extensions/model/mcpOAuth.js @@ -1,8 +1,14 @@ -export function mcpConnectionLabel(status) { - return ({ connected: '已连接', reconnect_required: '需要重新连接', disconnected: '未连接', not_required: '无需个人授权' })[status] || '未连接' +export function mcpConnectionLabel(status, mode = 'oauth') { + const isStatic = ['bearer', 'api_key'].includes(mode) + return ({ connected: isStatic ? '已配置' : '已授权', reconnect_required: isStatic ? '需要更新凭证' : '需要重新授权', disconnected: isStatic ? '未配置' : '未授权', not_required: '无需个人凭证' })[status] || '未配置' } /** 展示连接实际使用的认证方式。 */ export function mcpAuthLabel(mode) { - return ({ headers: '共享凭据', oauth: '个人账号授权', delegation: '内部服务认证' })[mode] || '共享凭据' + return ({ none: '无需认证', bearer: '个人 Bearer Token', api_key: '个人 API Key', headers: '共享凭证(所有用户共用)', oauth: '个人 OAuth 授权', delegation: '平台委托认证' })[mode] || '共享凭证(所有用户共用)' +} + +/** 需要当前用户配置凭证的认证方式。 */ +export function isPersonalMcp(mode) { + return ['oauth', 'bearer', 'api_key'].includes(mode) } diff --git a/frontend/src/modules/extensions/ui/McpCardList.vue b/frontend/src/modules/extensions/ui/McpCardList.vue index 70e8c1d09..428a1e784 100644 --- a/frontend/src/modules/extensions/ui/McpCardList.vue +++ b/frontend/src/modules/extensions/ui/McpCardList.vue @@ -43,12 +43,12 @@ variant="mini" :icon-color="getExtensionIconColor(server.slug, themeStore.isDark)" :title="formatExtensionCardTitle(server.name)" - :description="[server.description, server.auth_mode === 'oauth' ? mcpConnectionLabel(server.connection_status) : ''].filter(Boolean).join(' · ') || '暂无描述'" + :description="[server.description, isPersonalMcp(server.auth_mode) ? mcpConnectionLabel(server.connection_status, server.auth_mode) : ''].filter(Boolean).join(' · ') || '暂无描述'" @click="handleCardClick(server)" >