browser-use 的 MCP 双向设计:暴露浏览器与接入外部工具
本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。
这两个方向不是一套代码的两种用法,而是两份独立实现、两套生命周期、两种失败方式。 你在 browser_use/mcp/ 下看到的 server.py 和 client.py 之间几乎没有共享逻辑:前者把浏览器动作包装成 MCP 工具交出去,后者把外部 MCP 工具翻译成 browser-use 的动作收进来。把它们当成”同一个 MCP 集成”去理解,配置和排错都会走错方向。
站内已经有三篇相邻的内容:MCP 协议本身是什么 讲协议分层与消息流,MCP server 开发入门 讲你自己从零写一个 server 的步骤,MCP 任务编排 讲任务层怎么组织;这篇不重复协议知识,只做一件事——按 browser-use 这个具体仓库的代码,把它双向能力的实现方式和边界读清楚。
一、先分清两个方向各自在解决什么问题
server 方向解决的问题是:你已经有一个宿主 Agent(编程助手、桌面客户端、你自己的编排程序),它有推理能力但没有手,碰不到真实网页。browser-use 起一个 stdio 上的 MCP server,把导航、点击、输入、取页面状态这些动作变成工具描述交出去,宿主自己决定什么时候用哪一个。这里 browser-use 是被调用方,决策权在宿主。
client 方向解决的是反过来的问题:browser-use 自己的 Agent 会开浏览器干活,但干活途中需要浏览器以外的能力——查一份内部数据、读一封邮件里的验证码、往工单系统写一条记录。MCPClient 连上别人的 MCP server,把发现到的工具注册进 browser-use 的动作注册表,于是这些工具和 browser_navigate 一样出现在同一份可选动作里,由 browser-use 的 Agent 自己挑。这里 browser-use 是调用方。
两个方向可以同时存在,但它们在仓库里的入口完全不同。browser_use/mcp/__init__.py 直接导出 MCPClient 与 MCPToolWrapper,而 BrowserUseServer 走 __getattr__ 懒加载——注释写明了原因:只用 client 时不该把 server 模块拖进来。这个细节本身就说明了两条链路的独立性。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
BrowserUseServer | 把浏览器动作与一个自治 Agent 包装成 MCP 工具,跑在 stdio 上 | browser_use/mcp/server.py | 让别的 Agent 通过 MCP 操作浏览器时 |
CLIMCPServer | 另一种 server 形态:只暴露执行代码与截图两个工具 | browser_use/mcp/cli_mcp.py | 走 --cli-mcp 那条入口时 |
MCPClient | 连外部 server、发现工具、注册成 browser-use 动作 | browser_use/mcp/client.py | 想给 browser-use 的 Agent 加浏览器以外的能力时 |
MCPToolWrapper | 更早的一版同类封装,接口更简,生命周期更粗 | browser_use/mcp/controller.py | 读老代码或老示例时 |
| DXT 清单 | 声明命令行、用户可配项与工具列表,供桌面客户端安装 | browser_use/mcp/manifest.json | 以扩展形式分发给非工程用户时 |
| 配置读取辅助 | 读 config.json 与环境变量,供 MCP 侧取默认 profile 和模型配置 | browser_use/config.py 中标注 MCP 用途的那几个函数 | 排查”为什么起来的浏览器不是我想要的样子”时 |
二、当 server:一份被切成两种粒度的工具清单
server.py 里 handle_list_tools 返回的清单分成两层。第一层是细粒度的直接控制:browser_navigate、browser_click、browser_type、browser_get_state、browser_extract_content、browser_get_html、browser_screenshot、browser_scroll、browser_go_back,加上标签页的 browser_list_tabs、browser_switch_tab、browser_close_tab。第二层只有一个工具 retry_with_browser_use_agent,它启动 browser-use 自己的 Agent 去跑一个高层任务,工具描述里明确写了这是 multiple failures 之后的 last resort,不是首选。
这个”细粒度优先、自治兜底”的取向很值得注意。它假设宿主 Agent 自己有能力看页面、定位元素、决定下一步,只在反复交互失败时才把控制权整体让给 browser-use 的 Agent。你如果反过来用——每次都直接甩一个大任务给 retry_with_browser_use_agent——等于绕开了宿主的上下文,宿主拿到的只是一段结果文本。
工具之间的配合方式是围绕元素索引展开的:browser_get_state 返回当前页面的交互元素列表,每个元素带 index、标签名、文本,以及可能的 placeholder 和 href;browser_click 和 browser_type 再拿这个 index 去操作。browser_click 还接受 coordinate_x 与 coordinate_y 一组像素坐标作为替代路径,所以 browser_get_state 的返回里额外塞了 viewport 与 page 的宽高、当前滚动位置,让模型知道自己在多大的坐标系里点。截图不塞进 JSON,而是作为独立的 image 内容项返回——代码注释直接说明了这么做是为了不把 base64 埋在 JSON 里。
执行层面,这些工具几乎都不直接调浏览器 API,而是往会话的事件总线上派发事件再等它完成,比如导航派发 NavigateToUrlEvent、点击派发 ClickElementEvent 或 ClickCoordinateEvent、输入派发 TypeTextEvent。这条链路和 browser-use 平时跑 Agent 用的是同一套机制,MCP 层只是一层薄壳。
输入这块有一处安全动作值得单独说:_type_text 会用一段保守启发式判断文本是否可能是敏感数据(含 @ 且后半段像域名,或者足够长且混有数字、字母与 .-_),命中就把 TypeTextEvent 标成敏感,并且返回给宿主的字符串里不回显原文,只写脱敏后的占位。这不是完整的敏感数据治理,但它挡住了最常见的一种泄漏面——把你刚输入的密码原样写进宿主的对话历史。
server 还自己管一份会话表。默认的会话超时是十分钟,后台有个循环定期检查并关掉过期会话;browser_list_sessions、browser_close_session、browser_close_all 三个工具把这份表也交给宿主操作。浏览器是懒启动的:只有第一次调用 browser_ 开头的工具时才会创建 profile 和会话。
还有一件事读代码才知道:MCP 走 stdio 时,stdout 是协议通道。server.py 开头一大段都在干同一件事——在导入 browser_use 之前就设好 BROWSER_USE_LOGGING_LEVEL 与 BROWSER_USE_SETUP_LOGGING,然后把所有 logger 的 handler 全换成 stderr。cli_mcp.py 里也有对应处理:用 redirect_stdout 把执行代码产生的输出捞到缓冲区里,注释写明理由是 stdout 承载着 MCP 协议。任何一行日志漏到 stdout,客户端看到的都是协议解析失败。
三、当 client:把别人的工具翻译成 Agent 的动作
client.py 的模块文档给出了完整用法,这是仓库里的原文:
from browser_use import Tools
from browser_use.mcp.client import MCPClient
tools = Tools()
# Connect to an MCP server
mcp_client = MCPClient(
server_name="my-server",
command="npx",
args=["@mycompany/mcp-server@latest"]
)
# Register all MCP tools as browser-use actions
await mcp_client.register_to_tools(tools)
connect() 做的事是启一个后台任务跑 stdio 客户端,在里面初始化会话、调 list_tools 拿到工具表,然后靠一个事件对象把连接挂住不退出;外层则轮询等连接就绪,超时就抛错。这个结构意味着连接的存活依赖那个后台任务,disconnect() 会先置事件、再有限等待、超时后取消任务。
真正的翻译工作在 register_to_tools 往下一层的 _register_tool_as_action 里:每个 MCP 工具的 inputSchema 是一份 JSON Schema,代码用 create_model 动态生成一个 pydantic 模型当参数模型,required 字段设为必填,其余字段变成可选并带上 schema 里的 default,字段描述直接沿用 schema 的 description。生成的模型配了 extra='forbid',多传参数会被拦下来。然后包一个异步函数,函数体里调 session.call_tool,把返回内容拼成文本塞进 ActionResult,最后用注册表的 action 装饰器登记进去。
这里有几个可以直接读出来的取舍。第一,register_to_tools 支持 tool_filter 和 prefix:前者让你只注册需要的几个工具,后者给动作名加前缀。第二,返回值转换是有损的——_format_mcp_result 的兜底分支是 str(result),也就是说结构化返回最终都变成给模型看的文本。第三,结果写进 ActionResult 时带了 include_extracted_content_only_once,并且长期记忆只留一句”用过某个 server 的某个工具”。换句话说,外部工具的输出属于当步用掉的信息,不会一直留在上下文里,你的任务描述得让 Agent 在同一步就把它消化掉。还有一点:注册进动作空间的描述文本直接沿用对方 server 给的 description,你这边改不动,模型挑错工具时你能调的只有过滤和前缀。
Schema 转换本身也有精度损失。枚举被统一映射成字符串,约束信息不再传给模型;带 properties 的对象会递归生成嵌套模型,不带 properties 的对象直接退化成 dict;数组有 items 时生成带元素类型的 list,没有就是裸 list。对方 server 的 schema 写得越松,你这边拿到的参数校验就越弱。
controller.py 里的 MCPToolWrapper 是同一件事的更早一版:接口只接受注册表加命令与参数,没有工具过滤,没有前缀,connect() 把 stdio 上下文整个握在自己的调用栈里并阻塞等关闭事件,源码注释自己写着这套生命周期还应该管得更好。它的动作包装函数用 **kwargs 收参数,并显式排除一批注册表会注入的特殊参数名。两个文件的错误处理也不同:client.py 的失败路径返回带 error 且 success 为假的结果,controller.py 则把错误文本同时塞进 extracted_content。读老示例时注意区分,别把两边的行为混着记。
四、边界与代价:这个设计明确不管的事
server 侧的域名限制有一处必须看懂的语义。 retry_with_browser_use_agent 允许调用方传 allowed_domains,但代码只在传进来的列表非空时才覆盖配置:
# Override allowed_domains only when the client supplied a non-empty list.
# Treating an empty list as an override would silently disable any
# admin-configured allowlist on the default profile, since
# SecurityWatchdog interprets allowed_domains=[] as "no restrictions".
if allowed_domains:
profile_config['allowed_domains'] = allowed_domains
原因写在注释里:安全侧的 watchdog 把空的允许列表理解成”不限制”。所以传空列表如果被当成覆盖,等于让客户端一句话就把管理员配的白名单关掉。工具描述里也重复了这一点。这类”空值到底是重置还是不覆盖”的分歧,是所有跨进程配置合并都会遇到的坑,可以对照 最小权限设计 的思路来审自己的实现。
直接控制工具与自治 Agent 工具不共用一个浏览器实例。 browser_ 系列走的是 server 内部那个懒启动的共享会话,而 retry_with_browser_use_agent 现场用配置里的 profile 新建一个 Agent,跑完在 finally 里关掉它,不进 server 的会话表。这两条路径连合并默认值的方式都不一样:懒启动那条会先铺一层默认 profile 再让配置覆盖,Agent 那条直接拿配置构造。你如果指望”先用细粒度工具登录,再让自治 Agent 接着做”,这个假设不成立。
它不管你的目标站点答不答应。 这类工具驱动的是真实浏览器,默认还会用一个持久的用户数据目录,也就是可能带着你已有的登录态去操作真实账号。第三方站点的使用条款、验证码与反自动化机制、账号被判定为异常的风险,全都落在使用者头上。仓库里对应的能力只是提供了域名白名单和敏感输入标记这类护栏,没有任何一处承诺你的自动化行为是被允许的。这里的正确做法是限定到你自己有权操作的站点和账号、能拿到授权的接口优先走接口、涉及登录态的任务用独立账号和独立数据目录跑,而不是想办法让脚本看起来更像人。
client 侧几乎没有权限分层。 注册动作时域名过滤是空的,代码注释解释说基于页面对象的过滤已经移除。这意味着一旦某个外部工具注册进来,它在整个任务过程中都可选,不会因为”当前在哪个站点”而被限制。工具重名的去重也只在单个客户端实例的范围内记录,多个 server 之间的命名冲突要靠你自己用前缀避开。相关的取舍面可以对照 MCP 的安全边界 那篇看。
分发清单和实际实现会漂。 manifest.json 里列的工具条目少于 server.py 实际返回的那份清单,browser_get_html、browser_screenshot 和三个会话管理工具在清单里没有;清单里对切换标签页的描述说按索引,而 server 的输入 schema 要的是标签页 ID;清单还声明了几条 prompt,而 server 的 handle_list_prompts 返回的是空列表,handle_list_resources 同样返回空——这个 server 目前只提供工具这一面。清单里定义的用户可配项相当多,但真正被写进启动环境变量的只有其中几项。判断能力边界要以 server.py 为准,清单只是分发用的声明。
它也不替你解决模型侧的成本与配额。 browser_extract_content 是要调模型的:模型客户端是在会话初始化那一步顺手创建的,且只有从配置里读到 API key 才会创建,缺配置时这个工具直接返回错误,而纯粹的导航点击照样能用。各家模型服务商的计费与限流规则不同且会调整,以官方最新说明为准。
五、上手与避坑清单
日志漏进 stdout。 为什么会踩:你在自己的封装里加了一行 print 或者引入了某个会自己配 logging 的库,stdio 上的 JSON-RPC 就被污染,客户端表现为连接建立后立刻失败,且错误信息看不出跟日志有关。怎么避:照 server.py 的做法,在导入业务模块之前就把日志目的地钉到 stderr,任何调试输出也一律走 stderr。
以为浏览器一起来就绪。 为什么会踩:会话是懒启动的,第一次调 browser_navigate 才真正拉起浏览器和 profile,这一步耗时明显长于后续调用,你在宿主侧设的统一超时容易在这一下就打死。怎么避:把第一次浏览器操作单独看待,超时预算给足,或者接受它失败一次后重试。
用空的允许域名列表去”清空限制”。 为什么会踩:直觉上传空数组等于不限制,而实现刻意不把空列表当覆盖。怎么避:要放宽就明确写出你允许的域名;要收紧就配在默认 profile 里,别指望调用方每次传参。
指望细粒度工具和自治 Agent 共享状态。 为什么会踩:两条路径各自建浏览器实例,登录态、打开的标签页、页面上下文都不通。怎么避:一个任务只走一条路径;确实要交接,就把交接内容显式写成任务描述里的数据,而不是依赖浏览器状态。
照着 manifest 的工具列表写宿主提示词。 为什么会踩:清单与实现已经不同步,你会写出调用不存在的工具,或者漏掉真实存在的截图与会话管理工具。怎么避:接入前先用一次工具列表查询把真实清单打出来,以运行时返回的为准。
注册外部工具不加前缀、不加过滤。 为什么会踩:默认会把对方 server 的全部工具注册进来,名字直接进动作空间,多接一个 server 就多一份重名和误选的概率,动作越多模型挑错的概率也越高。怎么避:register_to_tools 的工具过滤和前缀两个参数都用上,只放进这个任务真的需要的几个。
把外部工具的返回当成会一直在的上下文。 为什么会踩:结果被标成只进上下文一次,长期记忆里只剩一句用过什么工具。怎么避:任务描述里要求 Agent 拿到结果的同一步就完成提取或落盘,不要指望后面几步还能回头看。
在共享的用户数据目录上跑带登录态的任务。 为什么会踩:默认的持久化 profile 让浏览器带着你日常的 cookie 和本地存储去访问目标站点,一旦任务里有误操作或者页面有引导性内容,影响的是真账号。怎么避:给自动化任务单独的数据目录和单独的账号,配上域名白名单,敏感字段走 browser-use 的敏感输入路径而不是明文塞进任务描述。
收个尾
判断该走哪个方向,问自己一句就够:决策权在谁手上。宿主 Agent 做决策、只是缺一双手,走 server;browser-use 的 Agent 做决策、缺的是浏览器以外的能力,走 client。两边都用,就当成两套独立配置分别验证。
接着往下读的顺序建议是:先把 browser_use/mcp/server.py 里 handle_list_tools 到 _execute_tool 这段完整看一遍,那是它对外承诺的全部;再看 _init_browser_session 明确默认 profile 到底给了什么;然后跳到 browser_use/mcp/client.py 的 _register_tool_as_action,那是外部工具进入动作空间的唯一入口。想知道自动化行为具体被什么挡住,去 browser_use/browser/watchdogs/ 那 14 个 watchdog 里找对应那个。想看它支持哪些模型接入,browser_use/llm/ 下 15 个 provider 目录、browser_use/agent/system_prompts/ 下 8 份系统提示词都能直接读;不过 examples/ 里那 124 个文件目前没有 MCP 相关的示例,这两条链路的用法只能回代码和文档里对。项目采用 MIT 许可证,代码你可以放心逐行读。
本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 拆解 browser-use 的动作注册表 和 browser-use 的判分器怎么用。