主题
知识源 MCP 与 Agent Tools BFF
本章目标:
- 看懂 a-cdm 给 Agent 喂数据的两条服务端通道:FastMCP 知识源 server(GitLab wiki + Nextcloud)与 Agent Tools BFF(nc / business / project 三族 REST 端点),以及它们为什么是两套机制而非一套
- 弄清这条 server-to-server 链路的安全模型:
X-ACDM-Service-Authservice token 网关 + 三头身份契约(X-ACDM-User-Id/-Agent-Name/-Thread-Id),以及 401 在 middleware 层与 dependency 层的两种语义- 掌握引擎侧
client.py的调用闭环:langgraph.config取身份 → 拼四头 → httpx 打 acdm-backend → 任何错误归一成ToolResponse形态,LLM 永不撞 raw stacktrace
TL;DR
a-cdm 的项目管家 Agent 需要读写企业知识(GitLab llm-wiki 全仓 + Nextcloud 项目目录)和业务对象(会议 / 报告 / 里程碑)。这些数据在 acdm-backend 一侧,deer-flow harness 因 test_harness_boundary 禁止 import app.*,只能走 HTTP。a-cdm 用了两套服务端通道:① acdm-backend/app/mcp/ 的 FastMCP server——把 4 个知识源 tool 用 MCP streamable HTTP 暴露,挂在 /mcp;② acdm-backend/app/api/agent_tools_*/ 的 BFF——nc 文件原语(7)、business 业务对象(13)、project 项目概览(3)三族普通 REST 端点。两套共用一个 mcp_service_auth 中间件做 X-ACDM-Service-Auth token 网关,BFF 额外要三头身份。引擎侧 deerflow/tools/{nc,business,project}_primitives/client.py 是薄 httpx 包装,从 langgraph.config 取身份拼四头,任何网络 / 配置 / 401 错误都映射成统一 ToolResponse dict,LLM 看到的永远是协议化的 success/code/message。
Overview(为什么是两套通道,不是一套)
Agent 要拿 a-cdm 的数据,核心矛盾是进程隔离:deer-flow harness(packages/harness/deerflow/)是可发布的 agent 框架包,被 tests/test_harness_boundary.py 强制永不 import app.*;而知识源(GitLab PAT、Nextcloud webdav)和业务对象(Postgres、可逆存储 substrate)全在 acdm-backend 进程里。harness 想读这些,只剩 server-to-server HTTP 一条路。
那为什么不用一套 HTTP API,而是 MCP + BFF 两套?因为两类需求的消费方不同:
| 维度 | FastMCP 知识源 server | Agent Tools BFF |
|---|---|---|
| 消费方 | LLM 自主发现并调用(MCP tool schema 进上下文) | LLM 调 harness 侧 @tool,或 harness 启动钩子直调 |
| 协议 | MCP streamable HTTP(/mcp) | 普通 REST(/agent-tools/*) |
| 数据 | GitLab wiki(全员可读)+ NC(按 project_name 软过滤) | NC 文件原语 + 业务对象(按 agent_name 硬解析 project_id) |
| 身份 | 仅 service token(无用户身份) | service token + 三头身份(归因 / 授权 / 审计) |
| 越权防护 | project_name prefix 硬过滤(传错只搜不到) | resolve_project_id(agent_name) DB 权威表反查 |
知识源是"读为主、全仓可见、LLM 自由检索",天然适合 MCP 的"tool 发现 + 自主调用"范式;BFF 是"按项目隔离、有写操作、要落审计 event",必须带真实用户身份走可逆存储 substrate。硬塞进一套会要么丢身份(BFF 需要)要么过度设计(MCP 不需要 thread_id)。
Architecture
| 组件 | 职责 | 入口文件 | Source |
|---|---|---|---|
| FastMCP server | 把 4 个知识源 tool 用 MCP streamable HTTP 暴露,挂 /mcp | app/mcp/server.py | acdm-backend/app/mcp/server.py:32-51 |
| wiki tools | search_wiki / read_wiki_file——GitLab llm-wiki 全仓搜读 | app/mcp/tools/wiki.py | acdm-backend/app/mcp/tools/wiki.py:39-160 |
| nextcloud tools | search_nextcloud / read_nextcloud_file——按 project_name prefix 硬过滤 | app/mcp/tools/nextcloud.py | acdm-backend/app/mcp/tools/nextcloud.py:111-234 |
mcp_service_auth 中间件 | 拦 /mcp/*(除 health)+ /agent-tools/*,校 X-ACDM-Service-Auth | app/main.py | acdm-backend/app/main.py:255-268 |
| nc BFF | 7 个 NC 文件原语端点(list/read/write/delete/move/...) | app/api/agent_tools_nc/router.py | acdm-backend/app/api/agent_tools_nc/router.py:57-572 |
| business BFF | 13 个业务对象端点(会议 / 日历 / 报告 / 成员 / 项目元) | app/api/agent_tools_business/router.py | acdm-backend/app/api/agent_tools_business/init.py:1-13 |
| project BFF | 3 端点 overview / sow / milestone advance | app/api/agent_tools_project/router.py | acdm-backend/app/api/agent_tools_project/router.py:50-302 |
via_agent_actor_dep | 解析三头身份 → set_via_agent_actor ContextVar | agent_tools_nc/dependencies.py | acdm-backend/app/api/agent_tools_nc/dependencies.py:27-61 |
scope.resolve_project_id | agent_name → project_id(DB 权威表 + 5min cache) | agent_tools_nc/scope.py | acdm-backend/app/api/agent_tools_nc/scope.py:55-85 |
ToolResponse[T] | 统一响应协议(success/code/message/data/suggested_action) | agent_tools_nc/response.py | acdm-backend/app/api/agent_tools_nc/response.py:46-90 |
| 引擎侧 client.py ×3 | httpx 薄包装,从 langgraph.config 取身份拼四头打 backend | deerflow/tools/*_primitives/client.py | deer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:58-127 |
| 引擎侧 tools.py ×3 | @tool 包装 client,docstring 调优给 LLM | deerflow/tools/*_primitives/tools.py | deer-flow/backend/packages/harness/deerflow/tools/nc_primitives/tools.py:24-223 |
Components / Subsystems
FastMCP 知识源 server
职责: 把 GitLab a-cdm/llm-wiki 单仓 + Nextcloud 项目目录,作为 4 个 MCP tool 暴露给 deer-flow lead_agent,走 platform-net 内网不经 Caddy。
关键实例: mcp_server = FastMCP(...) 在 acdm-backend/app/mcp/server.py:32-51。三个构造细节都是踩坑后的决策:
python
# 摘自 acdm-backend/app/mcp/server.py:32-38
mcp_server = FastMCP(
name="acdm-knowledge",
streamable_http_path="/", # 避免与 app.mount("/mcp",...) 叠成 /mcp/mcp
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False), # 内网 + service token 已护,关掉免 421
instructions=(...),
)streamable_http_path="/":FastMCP 默认 streamable HTTP 路径是/mcp,若不改,叠加app.mount("/mcp", ...)(app/main.py:560)会变成/mcp/mcp双前缀。enable_dns_rebinding_protection=False:FastMCP 默认只允许localhostHost,而 harness 容器以acdm-backend:8002访问会撞421 Invalid Host;内网 + service token 已是真正防线,DNS rebinding 在此无价值。get_mcp_app()(server.py:124-131)有 lazy init 副作用:首次调用初始化mcp_server._session_manager,随后app/main.py的 lifespan 必须跑mcp_server.session_manager.run()启动 task group,否则请求会RuntimeError: Task group is not initialized(app/main.py:135-195)。
4 个 tool(@mcp_server.tool 装饰器注册,server.py:56-119):
| tool | 参数 | 数据源 | 越权防护 | Source |
|---|---|---|---|---|
acdm_search_wiki | query, limit=10, path_prefix? | GitLab llm-wiki 全仓 blob 搜索 | 无需(全员可读) | acdm-backend/app/mcp/tools/wiki.py:39-111 |
acdm_read_wiki_file | path, ref=main | GitLab raw 文件 | 无需 | acdm-backend/app/mcp/tools/wiki.py:114-160 |
acdm_search_nextcloud | query, project_name, limit=10 | NC OCS Search | <base>/<project_name>/ prefix 过滤 | acdm-backend/app/mcp/tools/nextcloud.py:111-176 |
acdm_read_nextcloud_file | path, project_name | NC webdav range download | path 必须以 prefix 开头,否则 PermissionError | acdm-backend/app/mcp/tools/nextcloud.py:179-234 |
MCP NC tool 的 project_name 软隔离
acdm_search_nextcloud / acdm_read_nextcloud_file 没有真实用户身份(MCP 通道不传三头),那怎么防越权?答案是服务端 prefix 硬过滤 + LLM 必须自报 project_name:
python
# 摘自 acdm-backend/app/mcp/tools/nextcloud.py:184-188
base_prefix = f"{settings.NEXTCLOUD_BASE_PATH.rstrip('/')}/{project_name}/"
if not path.startswith(base_prefix):
raise PermissionError(
f"path outside project scope: {path!r} (required prefix: {base_prefix!r})")设计取舍:这是"软隔离"——LLM 传错 project_name 最多搜不到内容,不会越权到别的项目目录(prefix 是硬的)。tool 的 description(server.py:86-95)反复强调 LLM 必须从对话 / workspace 上下文确定 project_name,不确定要先 ask_clarification_tool 问用户而非猜测。_validate_project_name(nextcloud.py:61-66)还用正则 ^[^/\\\r\n\t\x00-\x1f]{1,100}$ 拒绝路径分隔符与控制字符,杜绝用 ../ 拼 prefix 逃逸。
Agent Tools BFF —— nc / business / project 三族
职责: 把"需要真实用户身份 + 落审计 + 按项目硬隔离"的操作,做成普通 REST 端点。三族共用一套架构(service token + via_agent_actor_dep + scope.resolve_project_id + ToolResponse[T] + per-call business_transaction)。
| 族 | 端点数 | 代表端点 | 写操作 | Source |
|---|---|---|---|---|
| nc(文件原语) | 7 | list_mounts list read search write delete move | write/delete/move 各开独立 txn | acdm-backend/app/api/agent_tools_nc/router.py:217-572 |
| business(业务对象) | 13 | list_meetings get_report create_meeting delete_meeting update_report_status | 5 个 write 各开独立 txn | acdm-backend/app/api/agent_tools_business/router.py:143-1020 |
| project(项目概览) | 3 | GET /overview GET /sow POST /milestone/advance | advance 走 txn + owner/admin 权限 | acdm-backend/app/api/agent_tools_project/router.py:127-302 |
关键模式:重逻辑全在 acdm-backend,harness 侧只是 ~30 行薄包装。agent_tools_nc/router.py:1-24 的 docstring 把这条 pipeline 写死:via_agent_actor_dep → resolve_project_id → load_mounts → validate_rel_path → ReversibleStorageClient → ToolResponse。这样 scope 解析、actor 归因、可逆 txn 这些"必须正确"的逻辑都在能 import app.* 的 backend,harness 不碰。
scope.resolve_project_id(scope.py:55-85)是越权防护的核心,它不信任 LLM 传的 project_id——只信 agent_name,反查 DB 权威表:
python
# 摘自 acdm-backend/app/api/agent_tools_nc/scope.py:71-85
stmt = select(ProjectAgentMount.project_id).where(
ProjectAgentMount.agent_name == agent_name,
ProjectAgentMount.role == "manager",
ProjectAgentMount.unmounted_at.is_(None),
)
result = await session.execute(stmt)
pid = result.scalar_one_or_none()
_AGENT_NAME_TO_PID_CACHE[agent_name] = (pid, now)
if pid is None:
raise ScopeError("scope_violation",
f"agent_name {agent_name!r} is not a provisioned project manager", ...)LLM 完全无法选择操作哪个项目——它能影响的只有 rel_path / meeting_id 这类项目内坐标,project 边界由 agent_name(三头身份之一,harness 注入,LLM 改不了)在服务端硬定。进程级 dict + 5min TTL cache 避免每 tool call 都打 DB。
service token 网关中间件
职责: 一个 FastAPI HTTP 中间件,把 /mcp/*(除 /mcp/health)和 /agent-tools/* 全部 gate 在 X-ACDM-Service-Auth 之后。两类流量都是 harness 容器过来的 server-to-server 调用,复用同一个 ACDM_MCP_SERVICE_TOKEN。
python
# 摘自 acdm-backend/app/main.py:256-268
@app.middleware("http")
async def mcp_service_auth(request: Request, call_next):
path = request.url.path
in_mcp = path.startswith("/mcp") and not path.startswith("/mcp/health")
in_agent_tools = path.startswith("/agent-tools")
if in_mcp or in_agent_tools:
expected = settings.ACDM_MCP_SERVICE_TOKEN
given = request.headers.get("x-acdm-service-auth")
if not expected or given != expected:
return JSONResponse(status_code=401,
content={"error": "service auth failed"})
return await call_next(request)设计要点:① 不挂在 FastMCP sub-app 上而是 FastAPI 全局 middleware,因为 mount 的 sub-app 中间件链不可靠,全局拦路径前缀最稳;② not expected(token 没配)也拒——ACDM_MCP_SERVICE_TOKEN 默认空串(config.py:177-180),空则 /mcp/* 直接 503/401,这是 fail-safe;③ /mcp/health 显式放行,供外部探针不带 token 探活(server.py:139-184)。
引擎侧 client.py 调用闭环
职责: harness 侧的 httpx 薄包装。三个 client(nc_primitives / business_objects / project_primitives)几乎同构,核心三步:取身份 → 拼四头 → 归一错误。
身份从 langgraph.config 取(nc_primitives/client.py:38-55):
python
# 摘自 deer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:44-55
try:
from langgraph.config import get_config
cfg = get_config().get("configurable", {})
except Exception:
return None
user_id = cfg.get("user_id")
agent_name = cfg.get("agent_name")
thread_id = cfg.get("thread_id")
if not (user_id and agent_name and thread_id):
return None
return {"user_id": user_id, "agent_name": agent_name, "thread_id": thread_id}user_id 由 frontend core/threads/hooks.ts 注入 config.configurable;agent_name 由 lead_agent factory 传;thread_id LangGraph runtime 自带。关键陷阱:langgraph.config.get_config() 在 graph factory 阶段(apply_prompt_template 调用时)不可用。project_primitives/client.py:54-93 为此专门加了 identity_override 参数 + get_project_overview_sync(:176-217)同步版,让 prompt.py 在 graph factory 阶段把已知身份显式喂入(2026-05-12 hot-fix,源注释引用"memory_context phase 1 hotfix #3 同款 bug")。
错误归一是这层的灵魂——LLM 永不撞 raw stacktrace。任何 timeout / RequestError / 401 / >=400 / 非 JSON 都映射成 ToolResponse 形态 dict(client.py:28-35、104-127):
| 引擎侧情形 | 映射 code | 处理位置 | Source |
|---|---|---|---|
| 身份缺(config 无 user/agent/thread) | internal_error | client _get_identity 返 None | deer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:66-71 |
ACDM_MCP_SERVICE_TOKEN env 未设 | internal_error | client 早返 | deer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:73-78 |
| httpx timeout / 连不上 | internal_error | client except 块 | deer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:92-101 |
| 401 且 body 是结构化 detail | 透传 backend 的 detail | client 解析 detail | deer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:104-114 |
| 422 Pydantic 校验失败 | validation_error | business client | deer-flow/backend/packages/harness/deerflow/tools/business_objects/client.py:102-113 |
backend 返 ToolResponse(success=False) | 原样透传 code | resp.json() 直返 | deer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:121-122 |
引擎侧 tools.py —— LLM 调优的 @tool 包装
@tool 包装是给 LLM 看的最后一层(nc_primitives/tools.py:24-223)。每个 tool 的 docstring 都按 LLM-tuned 风格写:简明 Usage 例 + 反面例 + I/O 范例 + 字段说明。例如 nc_delete_tool 默认 dry_run=True 防 AI 误删,docstring 明确写出"删会议附件别用 nc_delete,要用 delete_meeting_file_tool,否则日历显示鬼影附件"(tools.py:181-184)——这是把"业务对象与文件不联动"的坑直接写进 prompt。这些 tool 通过 get_available_tools() 装配进 lead_agent(agents/lead_agent/agent.py:386),详见 MCP 集成与工具装配章。
Data Flow / 控制流
知识源 MCP 调用(链路 B,LLM 自主检索 wiki/NC):
BFF 调用的两类 401 语义(这是排错最关键的区分点):
文字拆解 BFF 一次 nc_write 全程:
- LLM 调
nc_write_tool(rel_path, content)(tools.py:124-157)。 - tool fn →
post_nc_op("write", body)(client.py:58)。 _get_identity()从langgraph.config取{user_id, agent_name, thread_id},缺则返internal_errordict 不发请求(client.py:66-71)。- 检
ACDM_MCP_SERVICE_TOKENenv,空则internal_error(client.py:73-78)。 - 拼 4 头 POST
http://acdm-backend:8002/agent-tools/nc/write(client.py:80-91)。 - backend
mcp_service_auth校 service token(main.py:260-267);错 → 401 plain JSON。 via_agent_actor_dep解析三头,任一缺 → 401 + 结构化ToolResponse(missing_identity_headers);齐全则set_via_agent_actor写 ContextVar,使后续ReversibleStorageClient写自动归 via_agent actor(dependencies.py:37-61)。resolve_project_id(agent_name)DB 反查 project_id;非 manager →ScopeError(scope_violation)(scope.py:71-85)。validate_rel_path拒绝绝对路径 /..段,选 mount,拼 abs_nc_path(scope.py:155-217)。business_transaction("nc_write", rel_path=...)包写,落user_storage_events给前端 revert(router.py:434-435)。- 返
ToolResponse(success=True, data=WriteResult),HTTP 200。 - client
resp.json()原样透传,LLM 拿到协议化结果(client.py:121-122)。
注意第 6 / 7 步的两种 401:middleware 层 401 是 plain JSON {"error":"service auth failed"},LLM 看不懂(部署配置问题);endpoint dep 层 401 是结构化 ToolResponse,LLM 看得懂(可自纠提示)。client 的 if resp.status_code == 401 分支专门尝试解析 detail 是否结构化(client.py:104-114)。
Configuration
| Config | 默认值 | 含义 | 影响 | Source |
|---|---|---|---|---|
ACDM_MCP_SERVICE_TOKEN | ""(空) | /mcp + /agent-tools service-to-service token | 空则 /mcp/* 与 /agent-tools/* 拒所有请求;两端 .env 必须一致 | acdm-backend/app/config.py:177-180 |
ACDM_BACKEND_URL | http://acdm-backend:8002 | 引擎侧 client 打 backend 的基址 | platform-net 内网名;改了 client 全失联 | deer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:23 |
NC_OP_TIMEOUT_SECONDS | 30.0 | nc BFF httpx 超时 | 超时 → internal_error 让 LLM 重试 | deer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:24 |
GITLAB_WIKI_BASE_URL / _PROJECT_ID / _PAT | 见 config | wiki tool 连 GitLab | 缺 PAT → /health/mcp 报 gitlab error | acdm-backend/app/config.py:104-113 |
NEXTCLOUD_BASE_PATH | 见 config | NC project_name prefix 的 base | 决定 <base>/<project_name>/ 隔离边界 | acdm-backend/app/config.py:26 |
ACDM_AI_NC_WRITES_ENABLED | True | via_agent 模式 NC 写 kill switch | false → 所有 via_agent 写返 kill_switch_off(40310) | acdm-backend/app/config.py:184-189 |
安全相关:
ACDM_MCP_SERVICE_TOKEN是这条 server-to-server 链路的唯一密钥,生产openssl rand -hex 32生成,两端(acdm-backend/.env与deer-flow/.env)同名变量必须一致,否则全链路 401。改 .env 后须up -d --force-recreate(restart不重读 .env)。ACDM_AI_NC_WRITES_ENABLED=false是 NC 写的紧急 kill switch,只约束 via_agent 模式,direct/system 不受影响。
Common Pitfalls / 实战 Tips
- 两个 401 别混:看到
{"error":"service auth failed"}(无code字段)= service token 没配或两端不一致,改部署;看到ToolResponse(code=missing_identity_headers)= harness 没注入三头,查 frontendhooks.ts/ lead_agent factory(dependencies.py:13-17注释明确区分)。 - MCP NC tool 传错 project_name 不报权限错,只是搜不到:这是设计——prefix 硬过滤,LLM 传错最多空结果不会越权(
nextcloud.py:48-49instructions 写明)。排查"搜不到"先确认project_name等于Project.name。 - graph factory 阶段
langgraph.config不可用:apply_prompt_template拼 prompt 时调get_project_overview会拿不到身份。必须用get_project_overview_sync+ 显式identity_override(project_primitives/client.py:176-217),这是 2026-05-12 hot-fix 的踩坑结论。 /mcp/health不要带 service token:它故意排除在中间件外供探针无认证探活(main.py:258、server.py:139);若给它加 token 会让探针误判。- 业务对象与 NC 文件不联动:用
nc_delete_tool删会议附件文件,meeting_files表不会更新,日历会显示"鬼影附件"(看得见点开空)。删附件必须走delete_meeting_file_tool,删会议走delete_meeting_tool(nc_primitives/tools.py:181-184已把这个坑写进 docstring)。 - scope cache 5min TTL:刚 provision 的
project-{slug}agent 若立刻调 BFF 可能命中负缓存(pid=None)报scope_violation,等 5min 或调reset_scope_cache()(scope.py:50-52)。
References
acdm-backend/app/mcp/server.py:1-185— FastMCP server 实例 + 4 tool 注册 + health 路由(本章主源)acdm-backend/app/mcp/tools/wiki.py:39-160— wiki search/read tool 实现(GitLabClient 复用)acdm-backend/app/mcp/tools/nextcloud.py:61-234— NC tool + project_name prefix 硬过滤acdm-backend/app/main.py:255-268— mcp_service_auth 中间件(/mcp + /agent-tools 网关)acdm-backend/app/api/agent_tools_nc/router.py:1-572— nc BFF 7 端点 + pipeline docstringacdm-backend/app/api/agent_tools_nc/dependencies.py:27-61— via_agent_actor_dep 三头身份解析acdm-backend/app/api/agent_tools_nc/scope.py:55-217— resolve_project_id 权威表反查 + validate_rel_pathacdm-backend/app/api/agent_tools_nc/response.py:17-90— ToolResponse[T] 统一协议 + ErrorCode 枚举acdm-backend/app/api/agent_tools_project/router.py:127-302— project BFF 3 端点(overview/sow/advance)deer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:38-127— 引擎侧 httpx 闭环 + 错误归一deer-flow/backend/packages/harness/deerflow/tools/project_primitives/client.py:54-217— identity_override + sync 版踩坑deer-flow/backend/packages/harness/deerflow/tools/nc_primitives/tools.py:24-223— 7 个 @tool LLM 调优包装
Related Pages
| Page | Relationship |
|---|---|
| MCP 集成与工具装配 | 本章 BFF @tool / MCP tool 经该章 get_available_tools() 装配进 lead_agent |
| 控制面与引擎的耦合契约 | 本章四头身份契约是该章控制面↔引擎耦合的具体载体 |
| 鉴权与授权双层体系 | 本章 service token + scope.resolve_project_id 是该章授权层在 server-to-server 场景的延伸 |
| 可逆DB操作与存储层 | 本章 BFF 写操作经该章 business_transaction / ReversibleStorageClient 落审计 event |
| 项目管理与资源授权 | 本章 resolve_project_id 反查的 ProjectAgentMount 权威表由该章管理 |
| 知识库与Wiki协编 | 本章 wiki tool 读的 GitLab llm-wiki 仓内容由该章生产 |
| 系统整体架构 | 本章 FastMCP 知识源 server 是该章链路 B 的服务端实现 |