Skip to content

知识源 MCP 与 Agent Tools BFF

本章目标:

  1. 看懂 a-cdm 给 Agent 喂数据的两条服务端通道:FastMCP 知识源 server(GitLab wiki + Nextcloud)与 Agent Tools BFF(nc / business / project 三族 REST 端点),以及它们为什么是两套机制而非一套
  2. 弄清这条 server-to-server 链路的安全模型:X-ACDM-Service-Auth service token 网关 + 三头身份契约(X-ACDM-User-Id / -Agent-Name / -Thread-Id),以及 401 在 middleware 层与 dependency 层的两种语义
  3. 掌握引擎侧 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 知识源 serverAgent 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 暴露,挂 /mcpapp/mcp/server.pyacdm-backend/app/mcp/server.py:32-51
wiki toolssearch_wiki / read_wiki_file——GitLab llm-wiki 全仓搜读app/mcp/tools/wiki.pyacdm-backend/app/mcp/tools/wiki.py:39-160
nextcloud toolssearch_nextcloud / read_nextcloud_file——按 project_name prefix 硬过滤app/mcp/tools/nextcloud.pyacdm-backend/app/mcp/tools/nextcloud.py:111-234
mcp_service_auth 中间件/mcp/*(除 health)+ /agent-tools/*,校 X-ACDM-Service-Authapp/main.pyacdm-backend/app/main.py:255-268
nc BFF7 个 NC 文件原语端点(list/read/write/delete/move/...)app/api/agent_tools_nc/router.pyacdm-backend/app/api/agent_tools_nc/router.py:57-572
business BFF13 个业务对象端点(会议 / 日历 / 报告 / 成员 / 项目元)app/api/agent_tools_business/router.pyacdm-backend/app/api/agent_tools_business/init.py:1-13
project BFF3 端点 overview / sow / milestone advanceapp/api/agent_tools_project/router.pyacdm-backend/app/api/agent_tools_project/router.py:50-302
via_agent_actor_dep解析三头身份 → set_via_agent_actor ContextVaragent_tools_nc/dependencies.pyacdm-backend/app/api/agent_tools_nc/dependencies.py:27-61
scope.resolve_project_idagent_nameproject_id(DB 权威表 + 5min cache)agent_tools_nc/scope.pyacdm-backend/app/api/agent_tools_nc/scope.py:55-85
ToolResponse[T]统一响应协议(success/code/message/data/suggested_action)agent_tools_nc/response.pyacdm-backend/app/api/agent_tools_nc/response.py:46-90
引擎侧 client.py ×3httpx 薄包装,从 langgraph.config 取身份拼四头打 backenddeerflow/tools/*_primitives/client.pydeer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:58-127
引擎侧 tools.py ×3@tool 包装 client,docstring 调优给 LLMdeerflow/tools/*_primitives/tools.pydeer-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 默认只允许 localhost Host,而 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_wikiquery, limit=10, path_prefix?GitLab llm-wiki 全仓 blob 搜索无需(全员可读)acdm-backend/app/mcp/tools/wiki.py:39-111
acdm_read_wiki_filepath, ref=mainGitLab raw 文件无需acdm-backend/app/mcp/tools/wiki.py:114-160
acdm_search_nextcloudquery, project_name, limit=10NC OCS Search<base>/<project_name>/ prefix 过滤acdm-backend/app/mcp/tools/nextcloud.py:111-176
acdm_read_nextcloud_filepath, project_nameNC webdav range downloadpath 必须以 prefix 开头,否则 PermissionErroracdm-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(文件原语)7list_mounts list read search write delete movewrite/delete/move 各开独立 txnacdm-backend/app/api/agent_tools_nc/router.py:217-572
business(业务对象)13list_meetings get_report create_meeting delete_meeting update_report_status5 个 write 各开独立 txnacdm-backend/app/api/agent_tools_business/router.py:143-1020
project(项目概览)3GET /overview GET /sow POST /milestone/advanceadvance 走 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-35104-127):

引擎侧情形映射 code处理位置Source
身份缺(config 无 user/agent/thread)internal_errorclient _get_identity 返 Nonedeer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:66-71
ACDM_MCP_SERVICE_TOKEN env 未设internal_errorclient 早返deer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:73-78
httpx timeout / 连不上internal_errorclient except 块deer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:92-101
401 且 body 是结构化 detail透传 backend 的 detailclient 解析 detaildeer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:104-114
422 Pydantic 校验失败validation_errorbusiness clientdeer-flow/backend/packages/harness/deerflow/tools/business_objects/client.py:102-113
backend 返 ToolResponse(success=False)原样透传 coderesp.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 全程:

  1. LLM 调 nc_write_tool(rel_path, content)(tools.py:124-157)。
  2. tool fn → post_nc_op("write", body)(client.py:58)。
  3. _get_identity()langgraph.config{user_id, agent_name, thread_id},缺则返 internal_error dict 不发请求(client.py:66-71)。
  4. ACDM_MCP_SERVICE_TOKEN env,空则 internal_error(client.py:73-78)。
  5. 拼 4 头 POST http://acdm-backend:8002/agent-tools/nc/write(client.py:80-91)。
  6. backend mcp_service_auth 校 service token(main.py:260-267);错 → 401 plain JSON。
  7. via_agent_actor_dep 解析三头,任一缺 → 401 + 结构化 ToolResponse(missing_identity_headers);齐全则 set_via_agent_actor 写 ContextVar,使后续 ReversibleStorageClient 写自动归 via_agent actor(dependencies.py:37-61)。
  8. resolve_project_id(agent_name) DB 反查 project_id;非 manager → ScopeError(scope_violation)(scope.py:71-85)。
  9. validate_rel_path 拒绝绝对路径 / .. 段,选 mount,拼 abs_nc_path(scope.py:155-217)。
  10. business_transaction("nc_write", rel_path=...) 包写,落 user_storage_events 给前端 revert(router.py:434-435)。
  11. ToolResponse(success=True, data=WriteResult),HTTP 200。
  12. 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_URLhttp://acdm-backend:8002引擎侧 client 打 backend 的基址platform-net 内网名;改了 client 全失联deer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:23
NC_OP_TIMEOUT_SECONDS30.0nc BFF httpx 超时超时 → internal_error 让 LLM 重试deer-flow/backend/packages/harness/deerflow/tools/nc_primitives/client.py:24
GITLAB_WIKI_BASE_URL / _PROJECT_ID / _PAT见 configwiki tool 连 GitLab缺 PAT → /health/mcp 报 gitlab erroracdm-backend/app/config.py:104-113
NEXTCLOUD_BASE_PATH见 configNC project_name prefix 的 base决定 <base>/<project_name>/ 隔离边界acdm-backend/app/config.py:26
ACDM_AI_NC_WRITES_ENABLEDTruevia_agent 模式 NC 写 kill switchfalse → 所有 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/.envdeer-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 没注入三头,查 frontend hooks.ts / lead_agent factory(dependencies.py:13-17 注释明确区分)。
  • MCP NC tool 传错 project_name 不报权限错,只是搜不到:这是设计——prefix 硬过滤,LLM 传错最多空结果不会越权(nextcloud.py:48-49 instructions 写明)。排查"搜不到"先确认 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:258server.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 docstring
  • acdm-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_path
  • acdm-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 调优包装
PageRelationship
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 的服务端实现

公司内部参考 · 由 claude-wiki-gen 基于源码自动生成的二次分析