跳到主要内容

工具与 MCP:给模型装手脚

30 秒导读: 大模型只会"说话",不会"动手"。这一章讲 LibreChat 怎么把外部工具——尤其是 MCP(Model Context Protocol,模型上下文协议) 服务器提供的工具——接到 agent 上:从发现(有哪些工具)、装载(挑出这次要用的)、暴露(变成模型看得懂的函数)、执行(真去调用远端),到结果回流(把返回塞回对话)。全程还要管好"每个用户、每个请求各自的连接与鉴权"。

本章只讲工具与 MCP 这条链路。文件/RAG 类工具怎么进上下文,见 05-context-files-memory;工具在流式循环里何时被触发,见 03-agent-orchestration


1. 这是什么(零基础也能懂)

一句话定义: 工具是"模型能调用的函数";MCP 是一套标准协议,让任何外部服务(GitHub、数据库、内部 API)把自己的能力包装成一批工具,LibreChat 一接就能用。

为什么需要它。 模型本身是个纯文本预测器,它给不出今天的天气、改不了你仓库里的文件、查不了你公司的数据库。工具就是它的"手脚":模型输出一段"我要调用 search(query=...)",宿主(LibreChat)真的去执行,再把结果喂回去。

MCP 解决的是"接入成本"。 没有 MCP 时,每接一个新服务都要写一份专用适配代码。MCP 把"我有哪些工具、每个工具的参数长啥样、怎么调用"标准化成协议,于是 LibreChat 写一套 MCP 客户端,就能接上所有遵守协议的服务器。

LibreChat 里的工具其实分三大来源,本章聚焦第一种:

工具来源是什么本章是否详讲
MCP 工具外部 MCP server 暴露的工具,运行时动态发现✅ 主角
Actions用户上传 OpenAPI 规范生成的工具顺带对比
内建工具execute_code / file_search / web_search只讲它们怎么和 MCP 走同一条装载管线

用起来什么样。 管理员在 librechat.yaml 里写一段 MCP 服务器配置(命令或 URL),或用户在界面里加一个自定义 MCP server;之后在 agent 的工具列表里就能勾选该 server 的工具。模型调用时,一个名叫 search_repos_mcp_github 的工具就被触发了——注意名字里的 _mcp_,那是 LibreChat 给 MCP 工具打的统一标记(下面会讲)。

一句话直觉: 把 MCP server 当成"插在 agent 身上的外设"。发现工具 = 读外设的说明书;装载 = 把要用的接口接上;执行 = 真的按一下按钮;连接生命周期 = 管好每个外设的电源与配对状态。


2. 顶层全景(它大概怎么转)

先看一张全链路图。怎么读:从上到下是时间顺序——启动期发现一次,请求期按需装载,模型触发时才真正执行。

启动期(进程级,一次)
initializeMCPs() ──► createMCPManager(configs) ──► MCPManager(单例)
│ │
└─ getAppToolFunctions() ─► mergeAppTools() ─► 工具定义写进 Cache(便宜的"目录")

请求期(每条消息)
loadAgentTools({ definitionsOnly: true }) ← ToolService.js
│ 按能力/权限过滤 agent.tools
│ MCP 工具 → getOrFetchMCPServerTools()
│ ├─ 命中 Cache:直接拿定义(不连服务器)
│ └─ 未命中:reinitMCPServer() 连一次、抓工具、回填 Cache

只返回"工具定义"(JSON schema),不建实例 ──► 交给模型看

模型触发某个工具时(事件驱动)
loadToolsForExecution() ──► createMCPTool() ──► createToolInstance()
│ │
│ 返回一个 langchain tool,
│ 它的 _call 是个闭包

tool._call(args) ──► MCPManager.callTool()
│ ├─ getConnection():挑 app / user / 每请求连接
│ ├─ processMCPEnv():解析占位符、OBO/OAuth 头
│ └─ connection.client.request('tools/call') ──► 远端 MCP server

formatToolContent() ──► [content, artifact]

createToolEndCallback() ──► 按 artifact 类型转成 attachment,SSE 推给前端

部件一句话职责:

部件干什么在哪(相对克隆根)
initializeMCPs启动时建注册表 + 建 Manager,把 app 级工具定义灌进缓存api/server/services/initializeMCPs.js
MCPManager单例总管:挑连接、执行 callTool、聚合工具函数packages/api/src/mcp/MCPManager.ts
UserConnectionManagerManager 的父类:管每用户 / 每请求连接的生命周期packages/api/src/mcp/UserConnectionManager.ts
ConnectionsRepository一个连接池(按 owner 作用域),懒加载 + 失效重建packages/api/src/mcp/ConnectionsRepository.ts
MCPServersRegistry服务器配置的唯一真相源(YAML + config + 用户 DB)packages/api/src/mcp/registry/MCPServersRegistry.ts
createMCPToolCacheService把发现到的工具转成 LCAvailableTools 缓存条目packages/api/src/mcp/tools.ts
ToolService请求期装载:过滤、发现、OAuth 编排、产出定义api/server/services/ToolService.js
createMCPTool / createToolInstance把一条工具定义包成模型能调的 langchain toolapi/server/services/MCP.js
reinitMCPServer连服务器 → 抓工具 → 回填缓存的胶水api/server/services/Tools/mcp.js
MCPRequestContext每个 HTTP 请求的临时连接储物柜,请求结束自动清packages/api/src/mcp/request.ts
createToolEndCallback工具返回后把 artifact 转 attachment 回流api/server/controllers/agents/callbacks.js

一个关键的分层直觉贯穿全章:"工具定义"很便宜(就是段 JSON),"工具实例 + 连接"很贵(要真连远端)。 所以 LibreChat 把两者拆开——请求期只传定义给模型看,模型真点名了才去建实例、连服务器。


3. 核心原理(逐个机制,由浅入深)

3.1 工具的名字与"目录":发现即缓存

要解决的小问题: 模型看到的工具名必须全局唯一,且宿主要能从名字反推"这是哪个 server 的哪个工具"。

做法:统一命名。 每个 MCP 工具被命名成 <工具名><分隔符><server 名>,分隔符是常量 mcp_delimiter = '_mcp_';另有 mcp_prefix = 'mcp_' 用于用户鉴权变量的 key(packages/data-provider/src/config.ts:2540,2542)。发现阶段就按这个规则建好一张"目录"——LCAvailableTools,即工具名到函数签名的映射:

// 示意,非源码:发现到的每个工具被塞进目录
const name = `${tool.name}${mcpDelimiter}${serverName}`; // 如 search_repos_mcp_github
serverTools[name] = {
type: 'function',
function: { name, description, parameters: tool.inputSchema },
};

真实实现: updateMCPServerTools 就是这段命名 + 打包逻辑(packages/api/src/mcp/tools.ts:75-123,符号 createMCPToolCacheService)。它同时是缓存的守门人:

  • 普通 server:setCachedTools 把定义写进缓存,下次请求直接读,不用连服务器。
  • 请求级(request-scoped)server:配置里带 {{...}} 这类每请求才能解析的占位符,其工具定义绝不进持久缓存——isRequestScoped 判定后直接返回、跳过写缓存(tools.ts:58-73,104-109),避免把 A 用户的解析结果泄露给 B。

启动期,initializeMCPs 把所有 app 级 server 的工具函数一次性聚合并 mergeAppTools 灌进缓存(api/server/services/initializeMCPs.js:44-45;聚合逻辑 MCPManager.getAppToolFunctionsMCPManager.ts:236-245)。这张目录就是后续"便宜地判断相关性"的基础。

3.2 装载:定义优先,按需实例化

要解决的小问题: 一个 agent 可能挂了几十个工具,但一次对话只会用到其中几个。全部建实例、全部连服务器,既慢又浪费。

做法:两段式。 请求进来时走 loadAgentTools,默认 definitionsOnly: true——只产出定义,不建实例(api/server/services/ToolService.js:1069-1081)。真正建实例推迟到模型点名调用时,由 loadToolsForExecution 负责(ToolService.js:1429)。

装载前有三道闸:

闸门判断什么代码位置
能力(capability)agent 是否开启 tools / actions 等能力ToolService.js:552-584
MCP 权限当前用户是否有 MCP_SERVERS.USE 权限ToolService.js:561-562MCP.js:55-78
用户变量齐备server 要求的 user-provided 变量是否都填了ToolService.js:745-754

过滤后,对每个 MCP server 调 getOrFetchMCPServerTools(ToolService.js:708-791):

getOrFetchMCPServerTools(userId, serverName)

├─ 已在本请求内存过? → 直接返回
├─ getMCPServerTools() 命中缓存? → 返回缓存定义(不连服务器)★便宜路径
└─ 都没有 → reinitMCPServer() 真连一次、抓工具、回填

reinitMCPServer(api/server/services/Tools/mcp.js:30)是"连接 + 发现"的胶水:拿连接 → connection.fetchTools() 抓工具列表 → updateMCPServerTools 回填缓存(Tools/mcp.js:213-224)。抓工具本身带分页与预算上限(工具数 / 字节 / 超时),防止一个恶意 server 用超长 tools/list 拖垮进程(connection.ts:2234 起的 fetchTools)。

最终 loadToolDefinitions 把这批定义连同内建/Action 工具一起,产出模型能读的 toolDefinitions 和一张 toolRegistry(供后续按需实例化和 tool-search 用)(ToolService.js:852-868)。

3.3 把 MCP 工具变成 agent 工具:一个 _call 闭包

要解决的小问题: 模型框架(@librechat/agents 的 langchain 层)只认"有 _call 方法的工具对象"。得把一条静态的工具定义,变成一个真能执行的对象。

核心就是一个工厂函数 createToolInstance(api/server/services/MCP.js:731)。它把工具定义包成一个 langchain tool(...),并挂上 .mcp = true 标记,让上层能识别 MCP 工具:

// 示意,非源码:工厂产出的工具实例长这样
const toolInstance = tool(_call, {
schema, // 规整过的 JSON Schema(Google 系还会额外 sanitize)
name: normalizedToolKey,
description,
responseFormat: 'content_and_artifact', // 关键:返回值是 [内容, 附件] 二元组
});
toolInstance.mcp = true;

真正干活的是 _call 这个闭包(MCP.js:769-880)。重点看它做了什么——它不是自己去连服务器,而是每次调用时重新解析上下文再委托给 MCPManager.callTool:

  1. 权限复查:调用时再查一遍 canUseServers(MCP.js:780-785),不信任装载期的结论。
  2. 取运行期信息:从 config.configurable 里拿当前 userrequestBodyrequestScopedConnections、以及该 server 的 customUserVars(MCP.js:770-772,817-818)。
  3. 备好 OAuth 钩子:oauthStart / oauthEnd 用来在需要认证时往前端推事件(见 3.5)。
  4. 委托执行:调 mcpManager.callTool({...})(MCP.js:820-846)。

如果工具定义在缓存里找不到(比如 server 刚被删),createMCPTool 会走一次 reinitMCPServer 兜底;还找不到就返回一个 unavailable 桩工具——它有正确的名字和 schema,但 _call 只回一句"服务暂不可用",这样模型的这轮调用不会直接崩(MCP.js:672-714,桩来自 createUnavailableToolStubMCP.js:211-229)。为避免反复重连,找不到的工具还进了一个 10 秒的负缓存(MCP.js:52-53,703-706)。

3.4 执行:callTool 挑连接、组头、发请求

这是链路的心脏。 MCPManager.callTool(packages/api/src/mcp/MCPManager.ts:342)把一次工具调用落到某个真实连接上。它的顺序:

callTool()
├─ getConnection() 挑一个连接(app / user / 每请求,见 3.5)
├─ isConnected() 校验 连不上直接抛错
├─ preProcessGraphTokens 解析 {{LIBRECHAT_GRAPH_ACCESS_TOKEN}} 之类
├─ processMCPEnv() 把配置里的占位符/用户变量解析成真实值
├─ OBO / OAuth 头 需要时现取 on-behalf-of 令牌,塞进 Authorization
├─ setRequestHeaders() 把解析好的头挂到连接上
├─ client.request('tools/call', {name, arguments}) ★真正发给远端
└─ formatToolContent() 把 MCP 返回规整成 [content, artifact]

几个承重细节:

  • 配置在每次调用时新鲜解析。 占位符解析(processMCPEnv,MCPManager.ts:435-441)每次都做,因为同一个 server 配置对不同用户会解析出不同的密钥 / URL。
  • OBO(on-behalf-of)令牌每次刷新。 若 server 配了 OBO,callTool 每次都现换一个下游令牌(MCPManager.ts:446-485),并校验配置者是否仍有 CONFIGURE_OBO 权限——否则拒绝签发,防止越权残留。
  • 临时连接用完即断。 对 request-scoped server,若没有请求级储物柜托管,callToolfinally 里主动 disconnect(MCPManager.ts:424-425,541-555)。

3.5 连接生命周期:三种连接,各管各的

要解决的小问题: MCP 连接是有状态的长连接(stdio 子进程 / SSE / streamable-http / websocket 四种传输,见 connection.ts:6-13)。多用户、多请求并发时,连接既不能乱共享(串了别人的密钥),也不能每次都新建(太慢)。

LibreChat 把连接分成三档,共享程度递减、隔离程度递增:

连接档位谁能共用存在哪什么时候用
app 级所有用户共享appConnections(ConnectionsRepository(undefined))配置里不含任何每用户占位符的 server
user 级单个用户跨请求复用userConnections: Map<userId, Map<server, conn>>需要用户密钥 / OAuth / OBO 的 server
request 级(ephemeral)只在这一个 HTTP 请求内MCPRequestContext 的储物柜配置含每请求才能解析的占位符(如 {{body.xxx}})

挑哪一档,由配置决定。 getConnection 先看配置是否 requiresUserScopedConnection——是就走用户连接;否则尝试 app 级共享连接,拿不到再退回用户连接(MCPManager.ts:77-116)。为什么 app 级不能服务"要用户上下文"的 server?因为 {{MY_KEY}} 这类占位符只有在有真实 customUserVars 时才被 processMCPEnv 解析——共享连接没有这个上下文(ConnectionsRepository.ts:148-166,isAllowedToConnectToServer)。

连接池的懒加载与失效重建。 ConnectionsRepository.get(ConnectionsRepository.ts:46-97)拿连接时会check配置是否更新过:若 existingConnection.isStale(config.updatedAt) 为真(配置比连接新),就断掉旧连接重建——这让"改了 server 配置立即生效"成为可能,不用重启。

用户连接会因闲置被回收。 UserConnectionManager 记每个用户的 userLastActivity,超过 USER_CONNECTION_IDLE_TIMEOUT(默认 15 分钟,mcpConfig.ts:67-70)就断掉该用户所有连接(UserConnectionManager.ts:402-412,761-780)。

并发去重。 同一 userId:serverName 若有多个请求同时来建连接,pendingConnections 把它们合并成一个在途 Promise,避免同一 server 被并发连多次(UserConnectionManager.ts:159-194)。

每请求储物柜怎么自动清理。 MCPRequestContext(packages/api/src/mcp/request.ts)用一个 WeakMap<req, context> 给每个请求挂一个 { connections, pending } 储物柜,并监听响应的 finish / close 事件——请求一结束就把里面所有 request-scoped 连接 disconnect(request.ts:47-82,94-116)。这是防连接泄漏的最后一道保险。

request-scoped 连接的取用getUserConnection 里单独走一条分支:先查储物柜里有没有可复用的、没有就新建并存进储物柜、同时用 pending map 做并发去重(UserConnectionManager.ts:88-152)。

3.6 OAuth:发现不需认证,执行才认证

要解决的小问题: 很多 MCP server 要 OAuth 登录。但如果"看到工具列表"也要先登录,用户体验极差——他还没决定要不要用呢。

做法遵循 MCP 规范:tool listing 无需认证。 装载期若连接因 OAuth 失败,reinitMCPServer 会退到发现模式(discoverServerTools),只列工具、不执行(Tools/mcp.js:171-204;MCPManager.discoverServerToolsMCPManager.ts:118-233)。于是模型能看到工具定义,真正调用时才触发登录。

登录流以事件流形式推给前端。 ToolService 在装载期发现某 server 需要 OAuth 时,把 authURL 包成 run-step 事件用 SSE 推出去,并等一小段时间看用户是否完成认证(ToolService.js:886-955)。执行期同理,_call 里的 oauthStart / oauthEnd 回调负责把"请去这个 URL 登录 / 登录完成"这两个信号发给客户端(MCP.js:292-347,793-809)。

断线重连有节流。 reconnectServer 对普通 server 做 10 秒节流(MCP.js:414-424),但 request-scoped server 每条消息本就该重连,所以豁免节流——否则会把健康工具误判成不可用。

3.7 结果回流:一次工具调用如何变成一条附件

MCP 工具的 responseFormatcontent_and_artifact,意味着返回值是 [给模型看的文本, 给前端展示的附件] 二元组。文本直接进对话;附件则由 createToolEndCallback(api/server/controllers/agents/callbacks.js:647)按类型分流:

artifact 类型处理成代码位置
Tools.ui_resourcesMCP UI 资源附件(server 返回的可视化)callbacks.js:687-707
Tools.file_search文件引用 attachmentcallbacks.js:661-685
Tools.web_search搜索结果 attachmentcallbacks.js:709-729
content 里的 image_url存图并转成图片 attachmentcallbacks.js:731-769

每种都 push 一个 promise 进 artifactPromises,并用 writeAttachment 走 SSE(或 resumable 模式的 job emitter)推给前端(callbacks.js:581-585)。MCP 工具产出的可视化就是走 ui_resources 这条路进前端的。


4. 巧妙之处(可借鉴的技术)

  • 定义与实例分离。 "工具目录"是廉价 JSON,可缓存、可批量发给模型;"工具实例 + 连接"是昂贵资源,推迟到真被点名时才建。请求期 definitionsOnly: true 就是这个思想的落地(ToolService.js:1077-1081)。

  • 请求级连接靠 WeakMap 自动回收。WeakMap<req, ctx> + 监听 resfinish/close,把"连接何时清理"绑死在"请求何时结束"上,不靠人工记账(request.ts:26,94-116)。

  • 失效重建而非定时轮询。 连接是否过期,靠 isStale(config.updatedAt) 按需比对,而不是后台轮询;配置一改,下次取连接自然重建(ConnectionsRepository.ts:59-79)。

  • 找不到工具返回桩而非报错。 createUnavailableToolStub 让"server 临时挂了"退化成一句友好提示,而不是让整轮 agent 崩掉,再配 10 秒负缓存防重连风暴(MCP.js:211-229,672-714)。

  • 权限在执行时复查。 装载期查一次、_call 里再查一次 canUseServers(MCP.js:780-785),避免"装载后权限被撤销仍能调用"的窗口。


5. 边界与局限(诚实)

  • user 级连接目前仍被缓存,官方计划改成完全 ephemeral。 源码注释直接点明了这一取向(UserConnectionManager.ts:41-47,引 discussion #8790)。在改造前,长期空闲用户的连接靠 15 分钟 idle 超时兜底回收,不是即时释放。

  • Google/Vertex 的 schema 兼容是有损的。 Gemini 只接受 JSON Schema 的子集,MCP 工具若用了 union、非字符串 enum 等,会被 sanitizeGeminiSchema 削一刀才能用;无法表达的结构会被降级(MCP.js:746-764)。同一工具在 OpenAI/Claude 上按原样工作。

  • operationId + hostname 双重碰撞时 Action 工具会互相覆盖。 这是 Action(非 MCP)侧的已知病态情形,代码只能记一条 warning(ToolService.js:118-141,registerActionTools)。

  • tools/list 有硬预算。 工具数、字节、页数、超时都有上限(mcpConfig.ts:60-66),超预算的 server 会被截断,不是全量返回。


6. 代码地图(导航索引)

主题文件路径关键符号
启动期初始化api/server/services/initializeMCPs.jsinitializeMCPs
Manager 单例 / 执行packages/api/src/mcp/MCPManager.tsMCPManagercallToolgetConnectiondiscoverServerTools
连接生命周期(用户/请求)packages/api/src/mcp/UserConnectionManager.tsUserConnectionManagergetUserConnectioncreateUserConnectionInternal
连接池(按作用域)packages/api/src/mcp/ConnectionsRepository.tsConnectionsRepositorygetisAllowedToConnectToServer
单条连接 / 传输packages/api/src/mcp/connection.tsMCPConnectionfetchToolsconnectsetRequestHeadersisStale
工具定义缓存packages/api/src/mcp/tools.tscreateMCPToolCacheServiceupdateMCPServerToolsisRequestScoped
请求期装载 / OAuth 编排api/server/services/ToolService.jsloadAgentToolsloadToolDefinitionsWrapperloadToolsForExecutiongetOrFetchMCPServerTools
MCP→agent 工具工厂api/server/services/MCP.jscreateMCPToolcreateToolInstancecreateMCPToolscreateUnavailableToolStubreconnectServer
连接+发现胶水api/server/services/Tools/mcp.jsreinitMCPServer
每请求连接储物柜packages/api/src/mcp/request.tscreateMCPRequestContextgetMCPRequestContextcleanupMCPRequestContext
结果回流api/server/controllers/agents/callbacks.jscreateToolEndCallbackwriteAttachment
命名常量packages/data-provider/src/config.tsConstants.mcp_delimiter(_mcp_)、mcp_prefix(mcp_)
运行期开关packages/api/src/mcp/mcpConfig.tsmcpConfigUSER_CONNECTION_IDLE_TIMEOUT