- 人工智能
- AI Agent
- AI 应用
- 大模型
- 交互助手
- 工具调用
- Agent 编排
- 桌面应用
【免费下载链接】OpenMausBot
Open-source Grok Bot alternative with a virtual machine that bots can use
本文围绕 OpenMausBot 的 MCP(Model Context Protocol)OAuth 登录能力展开,重点讲解如何在不接触用户正在运行的应用、不读取真实 OAuth 凭据、不登录任何真实服务的前提下,验证"从另一台电脑完成 MCP 登录"这条链路。读完后你将掌握:如何按文档给出的命令分层运行单元测试与 API 测试、如何用verify-mcp-sign-in.ts夹具驱动真实设置面板、以及这条链路背后 PKCE 交换、回调校验、粘贴回填、取消与登出的实现原理。
为什么需要"从另一台电脑登录"
MCP 服务器通常以 OAuth 授权码模式接入。OpenMausBot 的默认做法是最标准的本地回环:服务端在127.0.0.1上起一个临时监听,浏览器授权后被重定向回http://127.0.0.1:<port>/mcp-oauth/callback,回调直接落到进程内。
但当管理界面是从另一台电脑(或者经过反向代理的 Cloud / 托管工作区)打开的,这个回环地址对那台电脑毫无意义——浏览器会把用户带到一个打不开的127.0.0.1页面。OpenMausBot 的处理方式有两条:
- 能证明来源时:为当前 https 地址动态注册客户端,回调直接指向
https://<你的地址>/mcp-oauth/callback,由服务端公开回调接口接管,完全不在本机开监听; - 无法证明来源时:仍然走回环监听,但把状态标记为
pasteBack,界面上直接给出"粘贴回填"表单,让用户把浏览器地址栏里那串http://127.0.0.1:.../mcp-oauth/callback?...整段复制回来。
docs/verification/mcp-sign-in.md描述的正是这条链路的验证方法,也是本文的主干。
第一层:聚焦单元测试与 API 测试
文档给出的第一条命令把 6 个测试文件一次性跑完:
pnpm exec vitest run server/mcp-oauth.test.ts server/mcp-oauth-discovery.test.ts server/mcp-oauth-store.test.ts server/mcp-probe.test.ts src/lib/mcp-sign-in.test.ts src/components/McpServersPanel.test.ts第二条命令只挑server/index.test.ts里与 MCP OAuth 相关的用例,避免整套服务端测试的时间成本:
pnpm exec vitest run server/index.test.ts -t 'MCP OAuth|OAuth URL MCP'两组命令覆盖的职责边界很清晰,按下表理解即可:
| 测试文件 | 关注点 |
|---|---|
| server/mcp-oauth.test.ts | 授权码流程、PKCE、状态校验、过期、取消、token 刷新与登出、远程/粘贴两种完成方式 |
| server/mcp-oauth-discovery.test.ts | RFC 8414 / RFC 9728 元数据发现、resource_metadata解析、issuer 一致性 |
| server/mcp-oauth-store.test.ts | 独立凭据文件的读写、schema 校验、按 URL 绑定 |
| server/mcp-probe.test.ts | 探测 MCP 服务器:auth: "required"的产生与工具列表返回 |
| src/lib/mcp-sign-in.test.ts | 前端登录驱动:仅打开 https 链接、轮询、取消、粘贴提交 |
| src/components/McpServersPanel.test.ts | 设置面板行为:按钮状态、错误显示、登录后自动测试并启用 |
测试到底验证了哪些攻击面
server/mcp-oauth.test.ts里的 "MCP sign-in from another computer" 场景是重头戏。它用startFakeOAuth+startFakeHttpMcp起一套本地假服务,然后逐条断言:
- 所有者隔离:
alice启动的流程,bob去start会得到 409,status返回undefined,cancelFlow抛错,completeCallback得到 404,且oauth.counts.token保持为 0; - 回放防护:同一个回调地址第二次提交得到 409,并且 token 端点只被调用一次;
- 畸形回调:
not a URL、长度 16385 的超长输入、被替换成attacker.example的主机、http:改https:、混入user:pass@、换路径、追加#fragment、重复code、code与error并存、错误的iss、错误的state、错误的端口——全部被拒,且流程不被消耗、不会对用户提供的地址发起请求; - 过期与取消:无人完成的登录到点变成
expired并关闭监听;注册阶段或授权码交换阶段被取消时,token 不会被写入。
const variants = [ "not a URL", "x".repeat(16_385), callback.replace("127.0.0.1", "attacker.example"), callback.replace("http:", "https:"), callback.replace("127.0.0.1", "user:pass@127.0.0.1"), callback.replace("/mcp-oauth/callback", "/other"), callback + "#fragment", callback + "&code=duplicate", callback + "&error=access_denied", callback + "&iss=https%3A%2F%2Fwrong.example", ];这些变体对应的实现是 server/mcp-oauth.ts 中的acceptCallback:它先比对origin与pathname,再拒绝username/password/hash,要求所有查询参数都是单值(params.getAll(key).length === 1),随后用恒定时间比较state(sameSecret,见 server/mcp-oauth.ts),校验可选iss,最后要求code与error互斥且code不超过 4096 字节。任何一条不满足就直接 400,且不会把flow.consumed置真——这正是"无效 URL 不消耗待完成流程"的底层原因。
第二层:真实面板 + 伪造引擎工作区的端到端夹具
单元测试证明的是函数级契约,文档要求再跑一次真实界面:
node --experimental-strip-types scripts/verify-mcp-sign-in.ts脚本开头三行就交代了隔离边界(scripts/verify-mcp-sign-in.ts):
// Real MCP panel + server + fake OAuth provider, only in a disposable workspace. const fixture = await launchVerificationServer(); let oauth: Awaited<ReturnType<typeof startFakeOAuth>> | undefined; let mcp: Awaited<ReturnType<typeof startFakeHttpMcp>> | undefined;launchVerificationServer来自 scripts/control-omb.ts,它会:
- 用
mkdtempSync建一个临时数据目录,并把HOME、USERPROFILE、XDG_*、TMPDIR、OMB_DATA_DIR全部指向它; - 选一对空闲端口(
OMB_PORT与OMB_WEBHOOK_PORT),写入config.json,只装仓库自带的 fake engine(server/testing/fake-claude-cli.ts); - 设置
OMB_TEST_SEALED_PATH=1,避免扫描到开发者本机真实引擎 CLI; - 轮询
/api/health直到返回app: "openmausbot",并断言只播种了一个 starter bot,然后重命名为Fixture Starter。
夹具随后做的事,与文档描述完全一致:
oauth = await startFakeOAuth({ beforeToken: () => tokenPending }); mcp = await startFakeHttpMcp({ acceptBearer: oauth.isValid, wwwAuthenticate: oauth.challenge }); const api = fixtureApi(fixture.info.url); await runControlOmb(["doctor", "--url", fixture.info.url]); await api("POST", "/api/mcp/servers", { name: "documents", url: mcp.url }); await api("POST", "/api/mcp/servers/documents/test"); const pairing = await api("POST", "/api/auth/pairing", { scopes: ["admin", "client"] });也就是说:doctor打到这台临时服务器、新建一个名为documents的合成 MCP 服务器、配对一个具备admin与client双 scope 的浏览器会话。整个过程不会碰用户正在跑的 OpenMausBot,也不会读他们的 OAuth 凭据。
pasteBack是如何被"逼"出来的
夹具最巧妙的一处,是它同时要满足两个互相拉扯的约束:真实的伪造 OAuth 提供方说的是 loopback HTTP,而渲染层只允许打开 https 链接(mcpSignInLink只认protocol === "https:"且无用户名密码,见 src/lib/mcp-sign-in.ts)。
解决办法是一条仅存在于夹具中的代理路由:它把真实开始响应里的authorizationUrl换成https://oauth.example.test/authorize,但不改其它字段。
extraRoutes: [{ path: "/api/mcp/servers/documents/sign-in", method: "POST", handler: async (req, res) => { const response = await fetch(`${fixture.info.url}/api/mcp/servers/documents/sign-in`, { method: "POST", headers: { cookie: req.headers.cookie ?? "", "x-forwarded-for": "192.0.2.10" }, }); const body = await response.json(); if (response.ok) { authorizationUrl = body.auth.authorizationUrl; body.auth.authorizationUrl = "https://oauth.example.test/authorize"; } res.writeHead(response.status, { "content-type": "application/json" }).end(JSON.stringify(body)); }, }]注意请求头:x-forwarded-for会命中服务端"这是远程浏览器"的判断(server/routes/mcp-servers.ts 中remote = auth.kind === "session" && (isProxied(req) || !isLoopbackHost(req.headers.host))),因此服务端不会给这个流程发pasteBack;又因为来自代理的请求带不出可证明的 https origin,mcpCallbackOrigin返回null,于是它退回回环监听模式。夹具随后故意执行window.open = () => null模拟弹窗被拦截——这正好触发面板里"从另一台电脑登录"的<details>展开区与粘贴表单。
夹具验证的 8 条行为
文档列出的清单,逐条都能在脚本里找到对应断言:
| 文档中的验证点 | 脚本中的实现 |
|---|---|
| 代理管理会话能启动流程 | 上面的extraRoutes转发POST .../sign-in |
| 弹窗被拦截仍保留重开按钮与粘贴说明 | window.open = () => null后等待After you approve, your browser shows a page that can与#mcp-callback-documents出现 |
| 无效 URL 报错且不消耗流程 | 填入http://127.0.0.1:1/wrong?code=wrong&state=wrong,等待[role=alert],并断言oauth.counts.token === 0 |
| 延迟的 token 交换保持按钮禁用且旋转、保留已粘贴 URL、提供方返回后展示结果 | beforeToken: () => tokenPending挂起 token 端点,断言button[aria-busy=true]的disabled为true、输入框value仍是原 URL |
| 交换期间取消后立即可重新登录,且粘贴表单在新请求落定前可用 | 点Cancel→ 点Sign in→ 重新填表 → 再点Complete sign-in |
| 粘贴伪造提供方的重定向地址能完成真实 PKCE 交换 | 用fetch(authorizationUrl, { redirect: "manual" })取出location填入,最终oauth.counts.token === 2 |
| 已登录的 MCP 服务器在 Test 时返回工具 | 等待Connected: 1 tool |
| 登出取消下一个待完成流程并关闭其监听 | 登出后重新开始,/api/auth/logout后对location的fetch必须 reject |
几处细节值得展开:
为什么不跟随重定向:脚本用redirect: "manual"拿到伪造提供方 302 的location,再把这串 URL 粘贴进真实 UI。这样既没有让浏览器真的访问回环监听,也证明了"粘贴回填"链路本身可用——这正是文档强调的"证明的是无头完成流程,而不是与某个真实 OAuth 提供方的兼容性"。
const approved = await fetch(authorizationUrl, { redirect: "manual" }); const callbackUrl = approved.headers.get("location")!; await command("fill", "#mcp-callback-documents", callbackUrl); await clickButton("Complete sign-in");延迟交换的两个断言:aria-busy=true且带.animate-spin、disabled === true、输入框值不变,三者同时成立才算通过。对应的 UI 在 src/components/McpServersPanel.tsx:输入框disabled={completingSignIn},按钮aria-busy={completingSignIn},取消按钮只在signingIn时出现。
取消与旧请求的竞态:signInAbort.current === controller这一守卫(src/components/McpServersPanel.tsx)保证旧请求落定时不会污染新表单的忙碌状态;服务端侧则用held的tokenPending复现"旧请求尚未落定"的窗口。对应单元测试是does not let an old cancellation cancel a new attempt。
登出必须真的让回调失效:脚本断言登出后对先前location的fetch会 reject。这依赖signOut先cancel(name)关闭监听再删记录(server/mcp-oauth.ts),以及回调响应头里的Connection: close(server/mcp-oauth.ts)——否则一条 keep-alive 连接会把下一次登录的回调送到已经停止接收的监听上。
环境变量与浏览器准备
文档给出的复用方式与默认值:
# 复用已安装的 agent-browser 与 Chrome export OMB_AGENT_BROWSER_PATH=/path/to/agent-browser export AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chrome # 自定义截图证据目录(默认 .omb-scratch/mcp-oauth-evidence) export OMB_UI_EVIDENCE_DIR=/tmp/mcp-evidenceensureUiBrowser的解析顺序是OMB_AGENT_BROWSER_PATH→ 工具目录 →PATH,最后才下载固定版本;Chrome 同理走AGENT_BROWSER_EXECUTABLE_PATH,两者都落在同一个UI_TOOLS_DIR下。若显式设置了AGENT_BROWSER_EXECUTABLE_PATH但文件不存在,会直接报错并提示 "unset it to use the Chrome agent-browser installs"(scripts/testing/control-omb-ui.ts)。
夹具自身还给浏览器进程注入了严格环境(scripts/verify-mcp-sign-in.ts):HOME/USERPROFILE指向临时数据目录、TMPDIR指到其下的browser-tmp、AGENT_BROWSER_SESSION带上 PID 避免串会话、AGENT_BROWSER_HEADLESS=1、AGENT_BROWSER_NO_WEBMCP=1。这些是"绝不接触用户正在运行的应用"的具体落地方式。
脚本成功后输出一行 JSON,把验证过的项目显式列出,便于 CI 断言:
{"ok":true,"evidence":"/abs/path/.omb-scratch/mcp-oauth-evidence", "tested":["remote admin start","blocked popup","invalid URL retry","pending spinner and preserved input", "cancel and retry during pending completion","paste completion","MCP tools","logout cancellation"]}服务端实现:一次登录的完整生命周期
阶段一:发现授权元数据
discoverMcpAuth先向 MCP 服务器发一次initialize,若返回 401 就取WWW-Authenticate里的resource_metadata(challenge()见 server/mcp-oauth.ts),再按顺序尝试/.well-known/oauth-protected-resource<path>与/.well-known/oauth-protected-resource;拿到authorization_servers[0]作为 issuer 后,再试/.well-known/oauth-authorization-server、/.well-known/openid-configuration。
server/mcp-oauth-discovery.ts 里有几条硬性约束:isAllowedAuthUrl只放行 https 与 loopback http;所有抓取都带redirect: "error"(不跟随重定向)、5 秒超时、64 KiB 上限;必须声明支持S256;元数据里的issuer必须与抓取时使用的 issuer 一致(RFC 8414 §3.3)。最终把 MCP URL 本身作为 RFC 8707 的resource返回,使 token 具备受众绑定。
阶段二:注册客户端或复用预注册应用
如果用户在服务器条目上配置了oauth(clientId,可选clientSecret、scopes),就走"预注册应用"路径;否则尝试动态注册。注册时请求体固定为公共客户端:
client_name: "OpenMausBot", redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none",若元数据里没有registration_endpoint,抛McpOAuthError("no-registration", ...),并在提示里直接教用户"编辑服务器,在 Sign-in app 下填该应用的 client ID"——这是常见的一类失败。
回环端口不是随机的,而是对 URL 做 FNV-1a 哈希后映射到20000 + hash % 20000(server/mcp-oauth.ts)。这样做是为了让预注册应用每次回到同一个redirect_uri。因此当端口被占用且存在预注册客户端时,服务端不会退让到随机端口,而是报port-busy并提示"关闭占用该端口的程序"(对应测试explains a busy redirect port instead of moving to another one);只有动态注册场景才会listen(server, 0)。
阶段三:拼装授权 URL
authorize.searchParams.set("response_type", "code"); authorize.searchParams.set("client_id", client.id); authorize.searchParams.set("redirect_uri", redirectUri); authorize.searchParams.set("state", state); // randomBytes(32) base64url authorize.searchParams.set("code_challenge", createHash("sha256").update(verifier).digest("base64url")); authorize.searchParams.set("code_challenge_method", "S256"); authorize.searchParams.set("resource", meta.resource); if (scopes?.length) authorize.searchParams.set("scope", scopes.join(" "));state与verifier各 32 字节,flowId为randomUUID(),默认有效期 5 分钟(DEFAULT_LIFETIME_MS),到期定时器unref()以免拖住进程退出。此处还决定pasteBack:
...(server && starting.browser.remote ? { pasteBack: true } : {}),即"存在本地监听 + 浏览器在远程"这一组合才需要粘贴回填。
阶段四:两种回收路径
回环监听回收:server.on("request", ...)落到callback(),先校验Host必须等于redirectUri的 host、URL 必须以/开头,然后交给answer();响应固定Cache-Control: no-store、Referrer-Policy: no-referrer、Connection: close,正文只使用固定话术("Signed in. You can close this tab and return to OpenMausBot." / "Invalid or expired sign-in..."),绝不回显code或提供方自己的错误文本。
公开回调回收:publicCallback(host, search)遍历所有流程,要求该流程没有本地监听、host 匹配、且state恒定时间相等,然后调用同一个acceptCallback。所以远程 https 回调与本地监听共享全部校验逻辑,包括iss比对(RFC 9207,允许 Google 那种 issuer 尾部斜杠差异)。
粘贴回收:completeCallback先在本地解析 URL(长度上限 16384),再走同一套校验,绝不向用户提供的地址发起请求。
阶段五:兑换、落盘与善后
授权码兑换固定走表单 POST,形参包含grant_type=authorization_code、code、redirect_uri、code_verifier、resource,10 秒超时。若流程在兑换期间被取消或条目被移除,服务端不会复活登录,而是把刚拿到的 token 送去revocationEndpoint,并返回内部标记DISCARDED,最终对调用方表现为 409:
if (this.flows.get(flow.name) !== flow || flow.status.phase !== "waiting") { if (flow.meta.revocationEndpoint) { void postForm(flow.meta.revocationEndpoint, { token: tokens.refresh ?? tokens.access }, flow.client).catch(() => undefined); } return DISCARDED; }成功时写入的记录包含issuer、clientId、可选的tokenAuth、redirectUri、tokenEndpoint、revocationEndpoint与tokens。tokenAuth依据 RFC 8414 §2 推导:没有 secret 即none;有 secret 且元数据未声明或声明支持client_secret_basic,默认用 Basic,并按 RFC 6749 §2.3.1 先做表单编码;仅当服务器只接受client_secret_post时才把 secret 放进表单。
凭据存放:为什么 token 不在 config.json 里
server/mcp-oauth-store.ts 的第一行注释就把设计意图写死了:与config.json分开存放,这样粘贴或导出配置时不会带上 token;并且 token 永远不会跟着条目迁移到别的 URL。
get(name, url)会校验记录里的url与请求一致,因此把一个服务器的登录记录改指向另一个地址时,旧 token 自动失效(对应测试never hands a token to a different URL)。落盘用writeFileAtomic(..., { mode: 0o600 }),读取则是"每次都重读",因为另一个进程可能刚改过这个文件。
访问 token 的取用由accessToken统一把关:距过期不足 2 分钟(REFRESH_MARGIN_MS)就刷新,并发调用共享同一个Promise;刷新返回invalid_grant或invalid_client(或 400/401)才判定凭据已死并标记needs-sign-in,其它失败只要旧 token 仍在有效期内就继续使用。
最后一环是withMcpSignIn(server/mcp-oauth.ts):需要登录的服务器根本不会进入这一轮的服务器集合;已登录的会先剔除原有Authorization头再注入Authorization: Bearer <token>。这样引擎侧只看到请求头,永远拿不到 token 本体。
路由与界面契约
服务端路由集中在 server/routes/mcp-servers.ts,共四个动作:
| 方法与路径 | 作用 | 关键约束 |
|---|---|---|
POST /api/mcp/servers/<name>/sign-in | 启动流程 | 返回{ auth: McpSignInStatus };会话在启动后失效则撤销并 401 |
GET /api/mcp/servers/<name>/sign-in/<flowId> | 查询状态 | 非本浏览器持有者返回 404 |
DELETE /api/mcp/servers/<name>/sign-in[/<flowId>] | 取消 | 所有者不符抛 404 |
POST /api/mcp/servers/<name>/sign-in/<flowId> | 提交粘贴的回调 | 必须application/json,正文上限 20480 字节 |
POST /api/mcp/servers/<name>/sign-out | 登出 | 先撤销 token 再删记录 |
非 URL 服务器直接 400("Only servers reached by URL can be signed in to."),运行在别的电脑上的服务器 409(runsElsewhere)。所有者来自auth.kind === "session" ? auth.session.id : "loopback"——这就是"另一个浏览器的登录进行中"会得到 409 的来源。
前端驱动在 src/lib/mcp-sign-in.ts:先POST启动,拿到的链接必须过mcpSignInLink的 https 检查,否则立即取消并返回failed;随后每 1500ms 轮询一次状态,同时用onStarted把complete回调交给面板,使得"用户粘贴后立刻完成"不必等下一轮轮询。取消走DELETE且被记忆化(cancellation ??= ...),保证多次取消只发一次请求;abort事件与completed、sleep三者用Promise.race竞争,所以取消不必等待弹窗或状态请求落定。
常见失败与对应含义
| 现象 / 错误文本 | 来源与含义 |
|---|---|
This server does not offer an OAuth sign-in. Add its token as a header instead. | 发现阶段拿不到可用的元数据(not-oauth) |
This server only signs in apps registered with it in advance. Edit the server and add that app's client ID under Sign-in app. | 无registration_endpoint(no-registration) |
Port <n> on this computer is in use... | 预注册客户端 + 固定回环端口被占用(port-busy) |
A sign-in is already in progress in another browser. | 同一服务器被另一个所有者占用(409) |
This redirect URL does not belong to the current sign-in. | 回调 origin/path/state/iss/参数重复等校验失败(400) |
This sign-in has ended or is already being completed. | 重复提交、已取消或已过期(409) |
This sign-in expired. Start again. | 超过 5 分钟有效期,监听已关闭 |
Your session ended. Start again. | 管理会话失效,revokeOwner已撤销流程 |
运行验证的实践建议
- 先在纯测试层定位问题:
mcp-oauth.test.ts里的远程场景不依赖浏览器,出错信息直接指到具体校验分支,比跑端到端快得多。 - 端到端只在隔离夹具中跑:直接执行
node --experimental-strip-types scripts/verify-mcp-sign-in.ts,不要把它接到正在使用的 OpenMausBot 上;脚本会自己launchVerificationServer()并在finally中依次关闭浏览器、预览、夹具服务、假 MCP 与假 OAuth。 - 保留证据:
OMB_UI_EVIDENCE_DIR下的before.png/waiting.png/authenticating.png/signed-in.png是四张关键帧,分别对应"初始状态""已弹出粘贴表单""交换中""已登录并连通"。 - CI 里复用已装浏览器:设置
OMB_AGENT_BROWSER_PATH与AGENT_BROWSER_EXECUTABLE_PATH可以跳过固定版本下载;若未设置,夹具会按大小与 SHA-256 校验后安装。 - 看日志而不是猜:临时数据目录与
server-<timestamp>-<pid>.log会随失败信息一起打印,确认服务端行为前先读它。
这条验证链路的价值不在于"跑绿了一次",而在于它把 MCP 远程登录的每一条安全边界——一次性状态、单值参数、恒定时间比较、所有者隔离、取消即作废、登出即关监听、token 不进配置文件——都变成了可重复执行、可截图留证、可在 CI 中回归的断言。
- 人工智能
- AI Agent
- AI 应用
- 大模型
- 交互助手
- 工具调用
- Agent 编排
- 桌面应用
【免费下载链接】OpenMausBot
Open-source Grok Bot alternative with a virtual machine that bots can use
相关推荐
RAWmakase 远程推理实战:用 ONNX Runtime 把 AI 蒙版跑在另一台电脑上
RAWmakase 远程推理实战:用 ONNX Runtime 把 AI 蒙版跑在另一台电脑上 RAWmakase 是一款免费、快速、兼容 Lightroom
pi-mcp-adapter认证实战:OAuth 2.1+PKCE如何安全登录远程MCP服务器
pi mcp adapter认证实战:OAuth 2.1+PKCE如何安全登录远程MCP服务器 pi mcp adapter 是一款为 Pi coding ag
MCP TypeScript SDK 客户端 OAuth 授权码登录实战:从 OAuthClientProvider 到 finishAuth 的完整接入指南
MCP TypeScript SDK 客户端 OAuth 授权码登录实战:从 OAuthClientProvider 到 finishAuth 的完整接入指南
人工智能MCP 服务MCP Clients
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考