From 688ea464f9cb05a1a3137f2d895d2a1d250cfbec Mon Sep 17 00:00:00 2001 From: Microbiosis <86361982+Microbiosis@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:21:29 +0800 Subject: [PATCH 1/5] Add mcp-oauth-console: OAuth sign-in for MCP servers Run the authorization-code + PKCE flow for MCP servers that require OAuth, keep the token inside the Node process, write the Authorization header into the MCP configuration, and refresh it before it expires. The page separates two facts that are easy to conflate: what the server demands, probed with a handshake that matches the declared transport and only read as OAuth when the server sends a Bearer resource_metadata challenge, and what this machine holds, the local credential. Credentials live under context.dataDir. The MCP configuration is patched surgically, written atomically, and backed up before every write. --- README.md | 1 + README.zh-CN.md | 1 + .../.minimax-plugin/plugin.json | 17 + plugins/microbiosis/mcp-oauth-console/LICENSE | 191 +++ .../microbiosis/mcp-oauth-console/README.md | 117 ++ .../mcp-oauth-console/README.zh-CN.md | 117 ++ .../microbiosis/mcp-oauth-console/icon.png | Bin 0 -> 1574 bytes .../mcp-oauth-console/miniapp/client/app.js | 796 ++++++++++++ .../miniapp/client/index.html | 64 + .../miniapp/client/styles.css | 671 ++++++++++ .../mcp-oauth-console/miniapp/miniapp.json | 20 + .../miniapp/node/miniapp-api.ts | 109 ++ .../miniapp/node/oauth-service.mjs | 1101 +++++++++++++++++ .../mcp-oauth-console/miniapp/node/server.mjs | 398 ++++++ .../mcp-oauth-console/package.json | 6 + 15 files changed, 3609 insertions(+) create mode 100644 plugins/microbiosis/mcp-oauth-console/.minimax-plugin/plugin.json create mode 100644 plugins/microbiosis/mcp-oauth-console/LICENSE create mode 100644 plugins/microbiosis/mcp-oauth-console/README.md create mode 100644 plugins/microbiosis/mcp-oauth-console/README.zh-CN.md create mode 100644 plugins/microbiosis/mcp-oauth-console/icon.png create mode 100644 plugins/microbiosis/mcp-oauth-console/miniapp/client/app.js create mode 100644 plugins/microbiosis/mcp-oauth-console/miniapp/client/index.html create mode 100644 plugins/microbiosis/mcp-oauth-console/miniapp/client/styles.css create mode 100644 plugins/microbiosis/mcp-oauth-console/miniapp/miniapp.json create mode 100644 plugins/microbiosis/mcp-oauth-console/miniapp/node/miniapp-api.ts create mode 100644 plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs create mode 100644 plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs create mode 100644 plugins/microbiosis/mcp-oauth-console/package.json diff --git a/README.md b/README.md index 3d3a4cc4..9bb2001d 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [Agent Flow](plugins/huixin615/agent-flow/) | Import this plugin's Hook event file to inspect native task calls and sub-agent lifecycles, with exact task-to-child links when IDs are available. Choose a recorded chat or the latest imported record; no Host session files are opened. Requires MiniMax Code 3.1.4 or later | [Huixin615](https://github.com/Huixin615) | | [蹬王之王 · Expiry Guardian](plugins/hylouis233/expiry-guardian/) | Track credit and reset-card expiry from a local ledger you maintain, and get a "spend the card or let the credits burn first" recommendation with the reasoning spelled out | [Hylouis](https://github.com/Hylouis233) | | [MiniMax Pet](plugins/minimax-ai/minimax-pet/) | A lightweight companion with appearance, task and settings tabs, visible-session activity, answer-needed reminders and a transparent desktop pet. Requires MiniMax Code 3.1.6 or later | [MiniMax](https://github.com/MiniMax-AI) | +| [MCP OAuth Console](plugins/microbiosis/mcp-oauth-console/) | Sign in to MCP servers that require OAuth: device-code or authorization-code + PKCE flow, the `Authorization` header written into the MCP configuration, and an opt-in refresh before expiry | [Microbiosis](https://github.com/Microbiosis) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index c8a5be2e..d69ca666 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -81,6 +81,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [Agent 协作透视](plugins/huixin615/agent-flow/README.zh-CN.md) | 手动导入插件 Hook 记录,查看原生 task 调用与子 Agent 运行区间;有明确 ID 时关联父子任务。可选择记录会话或一键查看最近导入的记录,不读取宿主会话文件。需要 MiniMax Code 3.1.4 或更高版本 | [Huixin615](https://github.com/Huixin615) | | [蹬王之王](plugins/hylouis233/expiry-guardian/README.md) | 看你自己维护的本地账本,算出「该用卡还是该留积分」,并把理由说清楚 | [Hylouis](https://github.com/Hylouis233) | | [MiniMax Pet](plugins/minimax-ai/minimax-pet/README.zh-CN.md) | 提供外观/任务/设置、可见会话状态、需要回答提醒和透明桌宠;需要 MiniMax Code 3.1.6 或更高版本 | [MiniMax](https://github.com/MiniMax-AI) | +| [MCP OAuth 令牌台](plugins/microbiosis/mcp-oauth-console/README.zh-CN.md) | 给需要 OAuth 的 MCP 服务器完成登录:设备码或授权码 + PKCE 流程,把 `Authorization` 头写入 MCP 配置,并可选择在到期前自动续期 | [Microbiosis](https://github.com/Microbiosis) |
预览:Token 用量看板 diff --git a/plugins/microbiosis/mcp-oauth-console/.minimax-plugin/plugin.json b/plugins/microbiosis/mcp-oauth-console/.minimax-plugin/plugin.json new file mode 100644 index 00000000..f1fd30f1 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/.minimax-plugin/plugin.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "name": "mcp-oauth-console", + "displayName": "MCP OAuth 令牌台", + "version": "1.0.0", + "description": "为受 OAuth 保护的 MCP 服务器完成授权码 + PKCE 登录,自动续期,并把令牌写回本地 mcp.json。", + "author": "Microbiosis", + "icon": "icon.png", + "category": "Code", + "exampleQueries": [ + "打开 MCP OAuth 令牌台", + "为 hf-endpoints 重新授权并写入 mcp.json" + ], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/plugins/microbiosis/mcp-oauth-console/LICENSE b/plugins/microbiosis/mcp-oauth-console/LICENSE new file mode 100644 index 00000000..384384c1 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/LICENSE @@ -0,0 +1,191 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensed or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of tracking or otherwise improving the Work, + but excludes communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work or + a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Microbiosis + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/plugins/microbiosis/mcp-oauth-console/README.md b/plugins/microbiosis/mcp-oauth-console/README.md new file mode 100644 index 00000000..dbf9ca64 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/README.md @@ -0,0 +1,117 @@ +# MCP OAuth Console + +[简体中文](README.zh-CN.md) | English + +Sign in to MCP servers that require OAuth: runs the authorization-code + PKCE flow in your browser, keeps the token out of the page, writes the `Authorization` header into the MiniMax Code MCP configuration, and refreshes it before it expires. + +Author: [Microbiosis](https://github.com/Microbiosis) · Version: `1.0.0` + +## Install & use + +Copy this directory into the active MiniMax Code data directory under `plugins/`. `` defaults to `~/.minimax`, so the default install path is: + +```text +/plugins/mcp-oauth-console/ +``` + +Keep the hidden `.minimax-plugin/` directory. Restart MiniMax Code, confirm the plugin is recognized, then open "MCP OAuth 令牌台" from the plugin list or by asking the assistant. + +The page lists every MCP server in the MiniMax Code configuration and shows **two independent facts** per server: what the *server* demands (probed from the endpoint) and what *this machine* holds (the credential in the local configuration). A server can require OAuth and still be satisfied by a local token; a server can need no OAuth and still have no token. Keeping them apart stops either one from hiding the other. + +| Action | What it does | +|---|---| +| Detect | Re-probes the endpoint with a handshake that matches the declared transport, and shows the result in that server's own row | +| Authorize | Runs the authorization-code + PKCE flow: dynamic client registration, browser consent, callback, then a confirmation dialog showing exactly which field will change | +| Confirm write | Patches `mcpServers..headers.Authorization` in `mcp.json`, backs the file up first, then verifies the token with a real MCP handshake | +| Refresh | Exchanges the stored refresh token for a new access token and writes it back | +| Refresh expiring | Refreshes every stored token that is within 30 minutes of expiry | + +## How detection works + +The probe uses the transport the server declares in the configuration: + +- `streamable-http`: a real `initialize` JSON-RPC call. +- `sse`: a `GET` that opens the event stream. A `POST` to the `/sse` URL is not a valid call for that transport and answers `405`. + +A `401` on its own is **not** treated as OAuth. Only a `WWW-Authenticate: Bearer resource_metadata="…"` challenge is read as a declaration of OAuth, because that is how an MCP server points at its authorization server (RFC 9728). The page therefore distinguishes: + +| Result | Meaning | +|---|---| +| Requires OAuth | `401` plus a discoverable authorization server. The browser flow can run. | +| Requires a Bearer credential | `401` plus a Bearer challenge, but no `resource_metadata`. No authorization server can be discovered. | +| Requires a credential, scheme undeclared | `401` with no usable challenge. The endpoint wants *some* credential — an API key, for example — not necessarily OAuth. | +| OAuth not required | An unauthenticated handshake was accepted. | +| Undetermined | The endpoint answered with something else (`404`, `405`, `5xx`) or could not be reached. | + +When a server does not declare OAuth the browser flow is not offered: there is no authorization server to run it against, so the header has to be set by hand instead. + +## Data & access + +### Files read + +| File | Why | +|---|---| +| The MiniMax Code MCP server configuration (`mcp.json`) | The list of servers to inspect, and the header to write. | + +`mcp.json` is a Host file with no supported API for plugins. This plugin locates it by walking up from `context.dataDir`, then from the process working directory, then from the directory of the Node executable, and finally falls back to `~/.minimax/mcp.json`. **This relies on an unspecified layout**: on a Host that keeps the configuration somewhere else, the plugin finds no servers and shows an empty list. + +A credential directory left next to `mcp.json` by an earlier version of this plugin is read once and imported into `context.dataDir`. It is never written to again. + +### Files written + +| File | What | +|---|---| +| `mcp.json` | Only the `Authorization` header of one server entry. The patch is surgical: the rest of the file stays byte-identical. | +| `/mcp-credentials/.json` | The credential: client id, client secret, refresh token, access token, scope, expiry. | +| `/mcp.json.bak` | A copy of `mcp.json`, taken before every write. | + +Writes to `mcp.json` are atomic: a temporary file in the same directory, then a rename. The original file mode is carried over, because a temp file starts with the default mode. Writes are serialized so two of them cannot interleave. If a write fails the temporary file is removed and the previous content stays intact. + +### Network hosts + +The plugin talks to exactly two kinds of hosts: + +1. The MCP endpoints listed in the MCP configuration — for the detection probe and for the post-write verification handshake. +2. The OAuth endpoints of those servers, taken from their metadata: the authorization endpoint, the token endpoint, the protected-resource document, and the `.well-known` authorization-server metadata. + +Nothing else is contacted. There is no telemetry, no analytics, and no third-party service. + +A target URL is validated before the process requests it: `https` only (plain `http` is accepted for loopback addresses), no credentials inside the URL, and instance-metadata or link-local addresses are refused. + +### Spawned processes + +| Command | When | +|---|---| +| `explorer.exe ` (Windows) or `open ` (macOS) | Once per authorization, to open the consent page in the default browser. | + +Arguments are passed as an argv array and never through a shell. The URL is the authorization request built from the server's own metadata. + +### Where state is stored + +All durable state lives under `context.dataDir`, the private directory the Host creates for this plugin. Nothing is written outside it except the MCP configuration listed above. + +### Credentials + +Token material stays in the Node process. The page receives metadata only: scope, expiry, and an eight-character SHA-256 fingerprint used to tell two tokens apart. A fingerprint is not reversible and is not usable as a credential. Credentials are never placed in HTML, client JavaScript, log messages, or error responses. + +## Tested environment + +| | | +|---|---| +| MiniMax Code | 3.1.6 (Windows) | +| Platform | Windows 11, Node 22 or newer | +| Authorization servers | Hugging Face OAuth (`huggingface.co`), Exa OAuth (`auth.exa.ai`) | +| Verified flows | Dynamic client registration, authorization code + PKCE (S256), authorization code exchange, refresh token exchange, MCP `initialize` handshake after a write | + +Verified end to end against the Hugging Face authorization server and the Hugging Face Inference Endpoints MCP server. + +## Known limitations + +- The plugin can only manage servers that are already in the MCP configuration, or that you add through the page. It does not discover servers on its own. +- A server that needs a credential but does not declare OAuth cannot be signed in to from the page; the header has to be set by hand. +- The detection probe checks the handshake. A server may accept an unauthenticated `initialize` and still require a token for tool calls. +- The refresh timer runs while the Mini App is running. A token that expires while the app is stopped is refreshed the next time it starts. + +## License + +Apache-2.0. See [LICENSE](LICENSE). diff --git a/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md b/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md new file mode 100644 index 00000000..2038c11e --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md @@ -0,0 +1,117 @@ +# MCP OAuth 令牌台 + +简体中文 | [English](README.md) + +给需要 OAuth 的 MCP 服务器完成登录:在浏览器里跑授权码 + PKCE 流程,令牌全程不进页面,把 `Authorization` 头写进 MiniMax Code 的 MCP 配置,并在到期前自动续期。 + +作者:[Microbiosis](https://github.com/Microbiosis) · 版本:`1.0.0` + +## 安装与使用 + +把整个目录复制到 MiniMax Code 的数据目录下 `plugins/`。`` 默认是 `~/.minimax`,因此默认安装路径是: + +```text +/plugins/mcp-oauth-console/ +``` + +保留隐藏的 `.minimax-plugin/` 目录。重启 MiniMax Code,确认插件已被识别,然后从插件列表打开「MCP OAuth 令牌台」,或直接让助手打开它。 + +页面会列出 MiniMax Code 配置里的每一个 MCP 服务器,并为每一行显示**两件互相独立的事**:**服务端要求什么**(向端点探测得到)和**本机持有什么**(本地配置里的凭证)。一个服务器可能要求 OAuth、同时又被本地令牌满足;也可能不需要 OAuth、却并没有配令牌。分开显示,两者都不会互相遮蔽。 + +| 操作 | 行为 | +|---|---| +| 检测 | 按服务端声明的传输方式重新探测,结果显示在**该服务器自己那一行** | +| 授权登录 | 走完授权码 + PKCE 流程:动态注册客户端、浏览器同意、接收回调,然后弹出确认框,明确告诉你将改动哪个字段 | +| 确认写入 | 改写 `mcp.json` 里 `mcpServers..headers.Authorization`,先备份,再用真实的 MCP 握手验活 | +| 刷新令牌 | 用已存的 refresh_token 换新 access_token 并写回 | +| 刷新即将过期 | 刷新所有距过期不足 30 分钟的令牌 | + +## 探测是怎么做的 + +探针使用服务端在配置里声明的传输方式: + +- `streamable-http`:发真实的 `initialize` JSON-RPC 调用。 +- `sse`:发一个 `GET` 开事件流。对 `/sse` 发 POST 不是该传输的合法调用,只会得到 `405`。 + +**单凭一个 401 不等于 OAuth。** 只有 `WWW-Authenticate: Bearer resource_metadata="…"` 才被当作 OAuth 声明——这是 MCP 服务器指向自己授权服务器的方式(RFC 9728)。所以页面区分: + +| 结论 | 含义 | +|---|---| +| 需要 OAuth | 401 + 可发现的授权服务器,可以跑浏览器流程 | +| 需要 Bearer 凭证 | 401 + Bearer challenge,但没有 `resource_metadata`,找不到授权服务器 | +| 需要凭证·方式未声明 | 401 且没有可用的 challenge。端点要的是**某种**凭证(例如 API key),不一定是 OAuth | +| 未要求 OAuth | 未认证的握手被接受了 | +| 不确定 | 端点返回了别的东西(404、405、5xx),或根本连不上 | + +服务端没有声明 OAuth 时,页面不会提供浏览器授权流程——没有授权服务器可供对着跑,此时只能手工配置请求头。 + +## 数据与访问 + +### 读取的文件 + +| 文件 | 用途 | +|---|---| +| MiniMax Code 的 MCP 服务器配置(`mcp.json`) | 要检查的服务器列表,以及要写入的请求头 | + +`mcp.json` 是宿主文件,插件没有受支持的 API 访问它。本插件的定位方式依次是:从 `context.dataDir` 向上找、从进程工作目录向上找、从 Node 可执行文件所在目录向上找,最后回落到 `~/.minimax/mcp.json`。**这依赖未公开的布局**:如果宿主把配置放在别处,插件会找不到服务器并显示空列表。 + +旧版本插件遗留在 `mcp.json` 旁边的凭据目录会被读取一次并导入 `context.dataDir`,之后不再写入。 + +### 写入的文件 + +| 文件 | 内容 | +|---|---| +| `mcp.json` | 只改某个服务器条目的 `Authorization` 头。改写是手术式的:文件其余部分逐字节不变。 | +| `/mcp-credentials/.json` | 凭证:client id、client secret、refresh_token、access_token、scope、过期时间 | +| `/mcp.json.bak` | 每次写入前对 `mcp.json` 的备份 | + +对 `mcp.json` 的写入是原子的:先在同目录写临时文件,再 rename。原始文件权限会被带过去(临时文件默认是默认权限)。写入被串行化,两次写入不会交错。写入失败时临时文件会被删除,原内容保持不变。 + +### 出网主机 + +插件只访问两类主机: + +1. MCP 配置里列出的 MCP 端点——用于探测和写入后的验活握手。 +2. 这些服务器的 OAuth 端点——取自它们的元数据:授权端点、令牌端点、受保护资源文档,以及 `.well-known` 的授权服务器元数据。 + +除此之外不访问任何主机。没有埋点、没有分析服务、没有第三方服务。 + +请求前会校验目标 URL:只允许 `https`(回环地址允许 `http`)、URL 里不允许带账号密码、实例元数据地址与链路本地地址一律拒绝。 + +### 启动的子进程 + +| 命令 | 时机 | +|---|---| +| `explorer.exe `(Windows)或 `open `(macOS) | 每次授权一次,用于在默认浏览器打开同意页 | + +参数以 argv 数组传入,绝不经过 shell。URL 是依据服务端自己的元数据拼出的授权请求。 + +### 状态存放位置 + +所有持久状态都放在 `context.dataDir`——宿主为这个插件创建的私有目录。除了上面列出的 MCP 配置,不会写到它以外的地方。 + +### 凭据 + +令牌本体始终留在 Node 进程里。页面只拿到元信息:scope、过期时间,以及一个八位 SHA-256 指纹,用来分辨两个令牌是否不同。指纹不可逆,也不能当凭据用。凭据不会出现在 HTML、客户端 JavaScript、日志或错误响应中。 + +## 测试环境 + +| | | +|---|---| +| MiniMax Code | 3.1.6(Windows) | +| 平台 | Windows 11,Node 22 及以上 | +| 授权服务器 | Hugging Face OAuth(`huggingface.co`)、Exa OAuth(`auth.exa.ai`) | +| 已验证流程 | 动态客户端注册、授权码 + PKCE(S256)、授权码换令牌、refresh_token 换令牌、写入后的 MCP `initialize` 握手 | + +已针对 Hugging Face 授权服务器与 Hugging Face Inference Endpoints MCP 服务器端到端验证。 + +## 已知限制 + +- 只能管理已经在 MCP 配置里的服务器,或通过页面新增的服务器;不会自己发现服务器。 +- 需要凭证但没声明 OAuth 的服务器无法从页面登录,只能手工配置请求头。 +- 探测只验证握手。服务端可能接受未认证的 `initialize`,却在调用工具时才要令牌。 +- 续期定时器只在 Mini App 运行时工作。App 停掉期间过期的令牌,会在下一次启动时补刷。 + +## 许可证 + +Apache-2.0,见 [LICENSE](LICENSE)。 diff --git a/plugins/microbiosis/mcp-oauth-console/icon.png b/plugins/microbiosis/mcp-oauth-console/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..299a26cbf5c1427dffa9cd575cf721307d570765 GIT binary patch literal 1574 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV7=|>;uumf=gr;N9Fb5F_7BDH zN*t40jx3m@A#jD`h{82~A;W#9hIKB=6V;QM-fp_!Iw3Du_^7z>7spdwD|5UT=(0U- z4vcs42{>&0A!&-l#UFng7Vv9zUfN`Qd~;|s@7+uz9O)JMQ;7Q z+VlNc*1f)P;(=w;neIK@5%Dq?bm#T^{r{9*@ia!gnj^~T2}4Q1*Th_Vu`>}GpIDCO z#4fN@Vt4rd)BClU(Q~fW#@QT3`o97jTK?xMOo;EM)AkEGZ>^la!>4}Tg|#orV!ID- z4G8cmi29Kul*I@Nov(+hUWIHbj*XAkN|#&6e?ev6^ZSxvcK?~yFA_-*H<;a0#(2g^ zzl72KdH~l2CW&?ibaY*)m{GcGsq4HCVQW8`+3MMAA9>%$$6x1gcEXjIh6=|UTHH*2 zPm^w)+0I})`*&FN5&Nu1Y+r6yc*L8(Sa9m$yrfKZ#~fa1&NHPdC8Y+RdyG?WF};29 zt~O0_E62oyX%mAzpJu)CiI9%EV`HT-k0aLM2a{@p^XrK&uP*%0esO=1w)UU=+zCGMvNmy= z|6P>ouW5@p2v)su`cxCfV?$ymt*7pfdX*%ncOdCuPzej4`!;F|NW(R z>9S1?D}6X`#NYh7GVkf}bgjz@`%TThovQS5e3ma}8>P8_foSNz|MS*u5I(ouy{)ZH zDAVlt_qYF?TCQcTsM{ODK6ko9)iJH7$x{Rh??u)A%=k6?)3WE!yQWM!akgaN_W!DX z-#&jmHN3X`^}*WR`-q(SpO3zY`t01fRdCqjHPcc83_B1GbPU2zNIsfpf)(T$7XRi5-O#h75#2n`^6HJMF!qC&lGw+h(&G*h? zk*k_rMVov+YqaeSTOYYZzjSt0F6+COd#ca&eqZ;_rtH&`lb%gyI!#lfHFBb5N}kls z$lZDCNY^>py$dH>z2A9YX;J*2GNpMejVH=I&vYJ{xB2|{X>r$mYq=dibJoiERAfK) zQ9W)K_T5@<--_8kB%7Wkzk0OuD!*pi4Mquhsb5FS-xaZWR zZ|9tq^y-Rf{wEFBD;u8QSW~|%f9dnxxlsofN1ZNZ>HPit!>su-YkBth3z|N4bh|s- zYxS08(KkMRy%ipDKxc+#k7En7+RrklI~Sdmo1bg6`PiN0n#CTy2vIV@izrM+Ju?Ht m|NpbhtN4MXJsYT)XJ&Z(ZtAoneFio_5e83JKbLh*2~7YK=FPVN literal 0 HcmV?d00001 diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/client/app.js b/plugins/microbiosis/mcp-oauth-console/miniapp/client/app.js new file mode 100644 index 00000000..638a2c59 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/client/app.js @@ -0,0 +1,796 @@ +// MCP OAuth console - client logic. +// The page never receives token material: only scope, expiry and fingerprints. + +const REFRESH_WINDOW_MS = 30 * 60 * 1000; + +const els = { + total: document.getElementById('kpi-total'), + authorized: document.getElementById('kpi-authorized'), + refreshable: document.getElementById('kpi-refreshable'), + expiring: document.getElementById('kpi-expiring'), + servers: document.getElementById('servers'), + hint: document.getElementById('panel-hint'), + modalRoot: document.getElementById('modal-root'), + toastRoot: document.getElementById('toast-root'), + themeToggle: document.getElementById('theme-toggle'), + refreshAll: document.getElementById('refresh-all'), + addServer: document.getElementById('add-server'), +}; + +const view = { + data: null, + loading: true, + error: null, + busy: new Set(), + rows: new Map(), + checkedAt: new Map(), + modal: null, + modalRendered: null, +}; + +/* ------------------------------------------------------------------ */ +/* helpers */ +/* ------------------------------------------------------------------ */ + +/** + * Error text is owned by the server (see miniapp/node/errors.mjs). The page + * renders the message it receives and only falls back to a generic line when + * the response carries none, so a code is never described in two places. + */ +async function api(path, options = {}) { + const response = await fetch(path, { + method: options.method ?? 'GET', + headers: options.body ? { 'content-type': 'application/json' } : undefined, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + let payload = null; + try { + payload = await response.json(); + } catch { + payload = null; + } + if (!response.ok || !payload?.ok) { + const fallback = `操作失败(${payload?.error ?? `http_${response.status}`})`; + throw new Error(payload?.message || fallback); + } + return payload; +} + +function formatRemaining(ms) { + if (!Number.isFinite(ms)) return '有效期未知'; + if (ms <= 0) return '已过期'; + const minutes = Math.floor(ms / 60_000); + const hours = Math.floor(minutes / 60); + if (hours >= 1) return `剩余 ${hours} 小时 ${minutes % 60} 分`; + return `剩余 ${minutes} 分钟`; +} + +function formatTime(epochMs) { + if (!Number.isFinite(epochMs)) return '—'; + const date = new Date(epochMs); + const pad = (value) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function toast(message, tone = 'ok') { + const node = document.createElement('div'); + node.className = `toast toast--${tone}`; + node.textContent = message; + els.toastRoot.append(node); + while (els.toastRoot.children.length > 4) els.toastRoot.firstElementChild.remove(); + setTimeout(() => node.remove(), 4200); +} + +function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function badge(text, tone) { + const node = element('span', `badge${tone ? ` badge--${tone}` : ''}`); + node.append(element('span', 'badge__dot'), document.createTextNode(text)); + return node; +} + +/** + * Marks a row busy. This must NOT re-render the list: rebuilding would + * replace the button the user just pressed and swallow the click. + */ +function setBusy(name, value) { + if (value) view.busy.add(name); + else view.busy.delete(name); + applyBusyState(); +} + +function applyBusyState() { + for (const [name, row] of view.rows) { + const busy = view.busy.has(name); + for (const button of [row.btnApply, row.btnRefresh, row.btnAuthorize, row.btnDetect]) { + button.disabled = busy; + } + } +} + +/* ------------------------------------------------------------------ */ +/* rendering */ +/* ------------------------------------------------------------------ */ + +function render() { + renderKpis(); + renderServers(); + renderModal(); +} + +function renderKpis() { + if (!view.data) return; + const summary = view.data.summary; + els.total.textContent = String(summary.total); + els.authorized.textContent = String(summary.authorized); + els.refreshable.textContent = String(summary.refreshable); + els.expiring.textContent = String(summary.expiringSoon); + els.expiring.className = summary.expiringSoon > 0 ? 'kpi__value kpi__value--warn' : 'kpi__value'; +} + +function renderServers() { + els.servers.setAttribute('aria-busy', view.loading ? 'true' : 'false'); + + if (view.loading) { + const node = element('div', 'state'); + node.append(element('span', 'spinner'), document.createTextNode(' 正在读取本地 MCP 配置…')); + els.servers.replaceChildren(node); + view.rows.clear(); + els.hint.textContent = '读取中'; + return; + } + + if (view.error) { + const node = element('div', 'state state--error'); + node.append(element('p', 'state__title', '无法读取 MCP 配置'), element('p', null, view.error)); + els.servers.replaceChildren(node); + view.rows.clear(); + els.hint.textContent = '读取失败'; + return; + } + + const servers = view.data?.servers ?? []; + if (!servers.length) { + const node = element('div', 'state'); + node.append( + element('p', 'state__title', '还没有 MCP 服务器'), + element('p', null, '点击右上角「添加服务器」,填入受 OAuth 保护的 MCP 地址即可开始授权。'), + ); + els.servers.replaceChildren(node); + view.rows.clear(); + els.hint.textContent = '空列表'; + return; + } + + // Rows are updated in place. Tearing the list down and rebuilding it would + // replace the very button the user is clicking, and the click would be lost + // before it ever reached the server. + // The container only ever holds row articles: any leftover state block (and + // its infinite spinner) must go, or it shifts every row and keeps the page + // permanently animating. + for (const child of [...els.servers.children]) { + if (child.tagName !== 'ARTICLE') child.remove(); + } + const seen = new Set(); + for (const server of servers) { + seen.add(server.name); + let row = view.rows.get(server.name); + if (!row) { + row = createServerRow(server); + view.rows.set(server.name, row); + els.servers.append(row.node); + } + updateServerRow(row, server); + } + for (const [name, row] of view.rows) { + if (!seen.has(name)) { + row.node.remove(); + view.rows.delete(name); + } + } + applyBusyState(); + els.hint.textContent = `${servers.length} 个服务器 · 每 60 秒检查一次到期情况`; +} + +function currentServer(name) { + return view.data?.servers?.find((item) => item.name === name) ?? null; +} + +function metaItems(server) { + const items = []; + if (server.scope) { + const scope = element('span'); + scope.append(document.createTextNode('scope ')); + scope.append(element('code', null, server.scope)); + items.push(scope); + } + if (server.tokenFingerprint) { + const fingerprint = element('span'); + fingerprint.append(document.createTextNode('指纹 ')); + fingerprint.append(element('code', null, server.tokenFingerprint)); + items.push(fingerprint); + } + if (Number.isFinite(server.expiresAt)) { + items.push(element('span', null, `到期 ${formatTime(server.expiresAt)} · ${formatRemaining(server.remainingMs)}`)); + } + if (server.authorizationServer) { + items.push(element('span', null, `授权服务器 ${server.authorizationServer}`)); + } + if (server.dynamicRegistration) { + items.push(element('span', null, '支持动态注册')); + } + if (server.oauthReason === 'probe_failed') { + items.push(element('span', null, '探测失败,请重试')); + } + if (server.oauthReason === 'probe_indeterminate') { + items.push(element('span', null, '端点响应无法判断是否需要 OAuth')); + } + if (server.authRequirement === 'bearer') { + items.push(element('span', null, '该地址要求 Bearer 凭证,但未提供 resource_metadata,无法自动发现授权服务器')); + } + if (server.authRequirement === 'credential') { + items.push(element('span', null, '该地址要求凭证但未声明认证方式(不是 OAuth),需手动配置 Authorization 头')); + } + if (server.oauthProtected && !server.authorizationServer) { + items.push( + element( + 'span', + null, + server.oauthReason === 'no_resource_metadata' + ? '响应里没有 resource_metadata,无法自动发现授权服务器' + : '未能自动发现授权服务器', + ), + ); + } + const checkedAt = view.checkedAt.get(server.name); + if (checkedAt) { + items.push(element('span', null, `检测于 ${new Date(checkedAt).toLocaleTimeString()}`)); + } + for (const warning of server.warnings ?? []) { + items.push(element('span', 'server__warn', warning)); + } + if (!server.url) { + items.push(element('span', 'server__note', '本地进程 · 无 URL')); + } + if (server.type) { + items.push(element('span', null, server.type)); + } + return items; +} + +function createServerRow(server) { + const node = element('article', 'server'); + + const main = element('div'); + const title = element('div', 'server__name'); + const badgeSlot = element('span', 'badge-slot'); + const credBadgeSlot = element('span', 'badge-slot'); + title.append(document.createTextNode(server.name), badgeSlot, credBadgeSlot); + main.append(title); + + const urlNode = element('div', 'server__url'); + main.append(urlNode); + + const metaNode = element('div', 'server__meta'); + main.append(metaNode); + + const actions = element('div', 'server__actions'); + const name = server.name; + + const btnApply = actionButton('确认写入', 'btn btn--primary', () => openConfirmModal(name)); + const btnRefresh = actionButton('刷新令牌', 'btn btn--secondary', async () => { + setBusy(name, true); + try { + const result = await api('/api/refresh', { method: 'POST', body: { serverName: name } }); + toast(`${name} 已刷新${result.probe ? `,握手状态 ${result.probe.status}` : ''}`); + await loadState(); + } catch (error) { + toast(`${name} 刷新失败:${error.message}`, 'error'); + } finally { + setBusy(name, false); + } + }); + const btnAuthorize = actionButton('授权登录', 'btn btn--secondary', () => { + const target = currentServer(name); + if (target) openAuthorizeModal(target); + }); + const btnDetect = actionButton('检测', 'btn btn--link', async () => { + setBusy(name, true); + try { + await api('/api/discover', { method: 'POST', body: { serverName: name } }); + // The detection result belongs to this row, so it is rendered in the + // row itself. A floating toast would overlap the action column and make + // the next click land on a different row's button. + view.checkedAt.set(name, Date.now()); + await loadState(); + } catch (error) { + toast(`${name} 检测失败:${error.message}`, 'error'); + } finally { + setBusy(name, false); + } + }); + + actions.append(btnApply, btnRefresh, btnAuthorize, btnDetect); + node.append(main, actions); + + return { node, badgeSlot, credBadgeSlot, urlNode, metaNode, btnApply, btnRefresh, btnAuthorize, btnDetect }; +} + +function updateServerRow(row, server) { + row.badgeSlot.replaceChildren(renderRequirementBadge(server)); + row.credBadgeSlot.replaceChildren(renderCredentialBadge(server)); + row.urlNode.textContent = server.url || '(本地 stdio 进程,无 URL)'; + row.metaNode.replaceChildren(...metaItems(server)); + row.btnAuthorize.textContent = server.hasAuthorization ? '重新授权' : '授权登录'; + // A browser OAuth flow only makes sense when the server declared an + // authorization server. Otherwise the credential has to be set by hand. + const oauthFlowAvailable = server.authRequirement === 'oauth' || server.authRequirement === 'unknown'; + row.btnAuthorize.hidden = !server.url || !oauthFlowAvailable; + row.btnDetect.hidden = !server.url; + row.btnRefresh.hidden = !server.refreshable; + row.btnApply.hidden = !server.pendingGrant; +} + +/** + * Two orthogonal facts, shown as two separate badges: + * - what the SERVER requires (from the OAuth probe) - a property of the + * server, unchanged by what this machine happens to hold; + * - what THIS MACHINE holds (from mcp.json + the credential file). + * Collapsing them into one badge made a server look "fine" just because a + * token existed, and made "requires OAuth" read as "you have no credential". + */ +/** + * What the server demands, as declared by the server itself: + * oauth - 401 + a Bearer challenge carrying resource_metadata + * bearer - 401 + a Bearer challenge, but no discoverable auth server + * credential - 401 with no usable challenge: some credential, unknown scheme + * none - an unauthenticated handshake was accepted + * A plain 401 is NOT treated as OAuth: the server never said so. + */ +function renderRequirementBadge(server) { + switch (server.authRequirement) { + case 'oauth': + return badge('需要 OAuth', 'warn'); + case 'bearer': + return badge('需要 Bearer 凭证', 'warn'); + case 'credential': + return badge('需要凭证·方式未声明', 'warn'); + case 'none': + return badge('未要求 OAuth', null); + default: + if (server.oauthReason === 'probe_failed') return badge('探测失败', 'danger'); + if (server.oauthReason === 'probe_indeterminate') return badge('探测不确定', 'warn'); + return badge('未探测', null); + } +} + +function renderCredentialBadge(server) { + switch (server.credentialState) { + case 'pending': + return badge('待确认写入', 'info'); + case 'valid': + return badge('令牌有效', 'ok'); + case 'expiring': + return badge('即将过期', 'warn'); + case 'expired': + return badge('已过期', 'danger'); + case 'configured_unknown': + return badge('已配置·无到期信息', null); + case 'other_scheme': + return badge('其他认证方式', null); + default: + return badge('未配置令牌', null); + } +} + +function actionButton(label, className, onClick) { + const button = element('button', className, label); + button.type = 'button'; + button.addEventListener('click', onClick); + return button; +} + +/* ------------------------------------------------------------------ */ +/* modal */ +/* ------------------------------------------------------------------ */ + +function openModal(state) { + view.modal = state; + renderModal(true); + const focusable = els.modalRoot.querySelector('input, textarea, button'); + focusable?.focus(); +} + +function closeModal() { + view.modal = null; + renderModal(true); +} + +/** + * Builds the modal only when it actually changed. A background state refresh + * must not replace the buttons of a dialog the user is about to press. + */ +function renderModal(force = false) { + const modal = view.modal; + if (!modal) { + if (view.modalRendered) { + els.modalRoot.innerHTML = ''; + view.modalRendered = null; + } + return; + } + if (!force && view.modalRendered === modal) return; + view.modalRendered = modal; + + els.modalRoot.innerHTML = ''; + + const backdrop = element('div', 'modal-backdrop'); + backdrop.addEventListener('mousedown', (event) => { + if (event.target === backdrop) closeModal(); + }); + + const dialog = element('div', 'modal'); + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + dialog.setAttribute('aria-label', modal.title); + + const head = element('div', 'modal__head'); + head.append(element('h2', null, modal.title)); + const close = actionButton('关闭', 'btn btn--icon', closeModal); + close.setAttribute('aria-label', '关闭对话框'); + head.append(close); + + const body = element('div', 'modal__body'); + modal.build(body); + + const foot = element('div', 'modal__foot'); + for (const action of modal.footer ?? []) { + foot.append(actionButton(action.label, action.className ?? 'btn btn--secondary', action.onClick)); + } + + dialog.append(head, body, foot); + backdrop.append(dialog); + els.modalRoot.append(backdrop); + + dialog.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.stopPropagation(); + closeModal(); + return; + } + if (event.key !== 'Tab') return; + const focusable = [...dialog.querySelectorAll('input, textarea, button, a[href]')].filter( + (node) => !node.disabled && node.offsetParent !== null, + ); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }); +} + +function field(labelText, input, hint) { + const wrap = element('div', 'field'); + const label = element('label', null, labelText); + const id = `field-${Math.random().toString(36).slice(2, 9)}`; + label.setAttribute('for', id); + input.id = id; + wrap.append(label, input); + if (hint) wrap.append(element('p', 'field__hint', hint)); + return wrap; +} + +/* ---- authorize modal ---- */ + +function openAuthorizeModal(server) { + const draft = { + serverName: server?.name ?? '', + url: server?.url ?? '', + scope: '', + session: null, + error: null, + working: false, + }; + openModal({ + title: server?.hasAuthorization ? `重新授权 · ${server.name}` : '授权登录', + build: (body) => renderAuthorizeBody(body, draft), + footer: [ + { label: '取消', className: 'btn btn--secondary', onClick: closeModal }, + { label: '开始授权', className: 'btn btn--primary', onClick: () => startAuthorization(draft) }, + ], + }); +} + +function renderAuthorizeBody(body, draft) { + const steps = element('ol', 'steps'); + const items = [ + { key: 'discover', text: '读取授权服务器元数据' }, + { key: 'register', text: '动态注册客户端' }, + { key: 'browser', text: '在浏览器中完成授权' }, + { key: 'callback', text: '接收回调并换取令牌' }, + { key: 'apply', text: '确认写入 mcp.json' }, + ]; + const reached = draft.session ? 3 : 0; + for (const [index, item] of items.entries()) { + const li = element('li', null, item.text); + if (index < reached) li.dataset.state = 'done'; + else if (index === reached) li.dataset.state = 'active'; + steps.append(li); + } + body.append(steps); + + if (!draft.session) { + const nameInput = element('input'); + nameInput.type = 'text'; + nameInput.value = draft.serverName; + nameInput.placeholder = 'hf-endpoints'; + nameInput.addEventListener('input', () => { + draft.serverName = nameInput.value; + }); + body.append( + field('服务器名称(写入 mcp.json 的键名)', nameInput, '名称已存在时覆盖该条目的 Authorization 头,否则新建条目。'), + ); + + const urlInput = element('input'); + urlInput.type = 'url'; + urlInput.value = draft.url; + urlInput.placeholder = 'https://endpoints.huggingface.co/mcp'; + urlInput.addEventListener('input', () => { + draft.url = urlInput.value; + }); + body.append(field('MCP 地址', urlInput, '受 OAuth 保护的 MCP 服务端点。')); + + const scopeInput = element('input'); + scopeInput.type = 'text'; + scopeInput.value = draft.scope; + scopeInput.placeholder = 'read-endpoints write-endpoints'; + scopeInput.addEventListener('input', () => { + draft.scope = scopeInput.value; + }); + body.append(field('scope(可选,留空则用授权服务器默认值)', scopeInput)); + } else { + const notice = element('div', 'notice'); + notice.append( + element('p', null, '浏览器已打开授权页。如果未能自动跳转,请复制下面的链接在新标签打开:'), + ); + const link = element('a', 'btn btn--link', draft.session.authorizationUrl); + link.href = draft.session.authorizationUrl; + link.target = '_blank'; + link.rel = 'noreferrer'; + notice.append(link); + body.append(notice); + + const copyRow = element('div', 'server__actions'); + copyRow.append( + actionButton('复制授权链接', 'btn btn--secondary', async () => { + await copyText(draft.session.authorizationUrl); + toast(`${draft.serverName} 授权链接已复制`); + }), + ); + body.append(copyRow); + + const callbackInput = element('textarea'); + callbackInput.placeholder = 'https://127.0.0.1:PORT/oauth/callback?code=...&state=...'; + body.append( + field( + '回退:粘贴浏览器跳转后的完整地址', + callbackInput, + '如果回调页被浏览器拦截,把地址栏里的完整 URL 粘贴到这里再点「用回填完成」。', + ), + ); + + const manualRow = element('div', 'server__actions'); + manualRow.append( + actionButton('用回填完成', 'btn btn--primary', async () => { + const value = callbackInput.value.trim(); + if (!value) { + toast('请先粘贴回调地址', 'error'); + return; + } + draft.working = true; + try { + await api('/api/authorize/complete', { + method: 'POST', + body: { sessionId: draft.session.sessionId, redirectUrl: value }, + }); + toast(`${draft.serverName} 已获取令牌,请在列表中确认写入`); + closeModal(); + await loadState(); + } catch (error) { + toast(`${draft.serverName} 完成授权失败:${error.message}`, 'error'); + } finally { + draft.working = false; + } + }), + ); + body.append(manualRow); + } + + if (draft.error) { + body.append(element('div', 'notice notice--danger', draft.error)); + } +} + +async function startAuthorization(draft) { + if (!draft.serverName.trim()) { + toast('请填写服务器名称', 'error'); + return; + } + if (!draft.url.trim()) { + toast('请填写 MCP 地址', 'error'); + return; + } + draft.working = true; + draft.error = null; + try { + const result = await api('/api/authorize', { + method: 'POST', + body: { + serverName: draft.serverName.trim(), + url: draft.url.trim(), + scope: draft.scope.trim() || undefined, + }, + }); + draft.session = result; + renderModal(true); + } catch (error) { + draft.error = `${draft.serverName} 发起授权失败:${error.message}`; + renderModal(true); + } finally { + draft.working = false; + } +} + +/* ---- confirm modal ---- */ + +function openConfirmModal(serverName) { + const server = view.data?.servers?.find((item) => item.name === serverName); + openModal({ + title: '确认写入本地配置', + build: (body) => { + const list = element('dl', 'kv'); + const add = (term, value) => { + list.append(element('dt', null, term), element('dd', null, value)); + }; + add('目标服务器', server?.name ?? serverName); + add('MCP 地址', server?.url ?? '—'); + add('写入字段', `mcpServers.${serverName}.headers.Authorization`); + add('新令牌指纹', server?.pendingFingerprint ?? '—'); + add('当前指纹', server?.tokenFingerprint ?? '(尚未配置)'); + add('scope', server?.scope ?? '—'); + body.append(list); + body.append( + element( + 'div', + 'notice notice--warn', + '这会修改本机 mcp.json 中该服务器的 Authorization 头,并在 mcp-credentials 下写入凭据文件。原文件会先备份为 mcp.json.bak。', + ), + ); + }, + footer: [ + { label: '取消', className: 'btn btn--secondary', onClick: closeModal }, + { + label: '确认写入', + className: 'btn btn--danger', + onClick: async () => { + try { + const result = await api('/api/apply', { method: 'POST', body: { serverName } }); + toast( + `已写入 ${serverName}${result.probe ? ` · 握手状态 ${result.probe.status}` : ''}${result.created ? ' · 新建条目' : ''}`, + ); + closeModal(); + await loadState(); + } catch (error) { + toast(`${serverName} 写入失败:${error.message}`, 'error'); + } + }, + }, + ], + }); +} + +async function copyText(value) { + try { + await navigator.clipboard.writeText(value); + } catch { + const area = element('textarea'); + area.value = value; + document.body.append(area); + area.select(); + document.execCommand('copy'); + area.remove(); + } +} + +/* ------------------------------------------------------------------ */ +/* data flow */ +/* ------------------------------------------------------------------ */ + +async function loadState() { + try { + const payload = await api('/api/state'); + view.data = payload.state; + view.error = null; + } catch (error) { + view.error = error.message; + } finally { + view.loading = false; + render(); + } +} + +function subscribe() { + try { + const source = new EventSource('/api/events'); + source.addEventListener('state', () => { + void loadState(); + }); + source.onerror = () => { + // the browser retries the stream on its own + }; + } catch { + // event streams are optional; the page still polls on interaction + } +} + +function applyTheme(theme) { + const root = document.documentElement; + if (theme) root.dataset.theme = theme; + else delete root.dataset.theme; + try { + if (theme) localStorage.setItem('mcp-oauth-console.theme', theme); + else localStorage.removeItem('mcp-oauth-console.theme'); + } catch { + // storage is optional + } +} + +function initTheme() { + let stored = null; + try { + stored = localStorage.getItem('mcp-oauth-console.theme'); + } catch { + stored = null; + } + applyTheme(stored); +} + +els.themeToggle.addEventListener('click', () => { + const current = document.documentElement.dataset.theme; + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const effective = current ?? (prefersDark ? 'dark' : 'light'); + applyTheme(effective === 'dark' ? 'light' : 'dark'); +}); + +els.refreshAll.addEventListener('click', async () => { + els.refreshAll.disabled = true; + try { + const result = await api('/api/refresh-all', { method: 'POST' }); + const ok = (result.results ?? []).filter((item) => item.ok).length; + const failed = (result.results ?? []).length - ok; + toast(failed ? `${ok} 个已刷新,${failed} 个失败` : `${ok} 个令牌已刷新`, failed ? 'error' : 'ok'); + await loadState(); + } catch (error) { + toast(`刷新失败:${error.message}`, 'error'); + } finally { + els.refreshAll.disabled = false; + } +}); + +els.addServer.addEventListener('click', () => openAuthorizeModal(null)); + +initTheme(); +render(); +void loadState(); +subscribe(); diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/client/index.html b/plugins/microbiosis/mcp-oauth-console/miniapp/client/index.html new file mode 100644 index 00000000..fb5f5288 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/client/index.html @@ -0,0 +1,64 @@ + + + + + + MCP OAuth 令牌台 + + + +
+
+

MCP OAuth 令牌台

+

+ 为受 OAuth 保护的 MCP 服务器完成授权码 + PKCE 登录,自动续期,并把令牌写回本地 + mcp.json 与凭据文件。令牌本身只保存在本机,不会在页面上显示。 +

+
+
+ + + +
+
+ +
+
+
+ MCP 服务器 + — +
+
+ 已配置令牌 + — +
+
+ 可自动续期 + — +
+
+ 即将过期 + — +
+
+ +
+
+

服务器

+

正在读取本地 MCP 配置…

+
+
+
+
+ + +
+ + + + diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/client/styles.css b/plugins/microbiosis/mcp-oauth-console/miniapp/client/styles.css new file mode 100644 index 00000000..29514a0c --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/client/styles.css @@ -0,0 +1,671 @@ +/* Mini App client styles - semantic tokens only, no raw theme hex in components. */ + +:root, +[data-theme='light'] { + --mcode-bg: #ffffff; + --mcode-bg-secondary: #f5f5f5; + --mcode-surface: #ffffff; + --mcode-surface-muted: #fafafa; + --mcode-text: #171717; + --mcode-text-muted: #666666; + --mcode-text-subtle: #adadad; + --mcode-border: #0a0a0a14; + --mcode-border-strong: #0a0a0af2; + --mcode-accent: #0094fc; + --mcode-success: #04b54b; + --mcode-warning: #f56811; + --mcode-danger: #f73646; + --mcode-primary-action-bg: #171717; + --mcode-primary-action-text: #ffffff; +} + +[data-theme='dark'] { + --mcode-bg: #171717; + --mcode-bg-secondary: #1c1c1c; + --mcode-surface: #262626; + --mcode-surface-muted: #262626; + --mcode-text: #ededed; + --mcode-text-muted: #949494; + --mcode-text-subtle: #666666; + --mcode-border: #ffffff14; + --mcode-border-strong: #fffffff2; + --mcode-accent: #0077d9; + --mcode-success: #009c3d; + --mcode-warning: #e25507; + --mcode-danger: #e31937; + --mcode-primary-action-bg: #ffffff; + --mcode-primary-action-text: #171717; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --mcode-bg: #171717; + --mcode-bg-secondary: #1c1c1c; + --mcode-surface: #262626; + --mcode-surface-muted: #262626; + --mcode-text: #ededed; + --mcode-text-muted: #949494; + --mcode-text-subtle: #666666; + --mcode-border: #ffffff14; + --mcode-border-strong: #fffffff2; + --mcode-accent: #0077d9; + --mcode-success: #009c3d; + --mcode-warning: #e25507; + --mcode-danger: #e31937; + --mcode-primary-action-bg: #ffffff; + --mcode-primary-action-text: #171717; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0 24px 48px; + background: var(--mcode-bg); + color: var(--mcode-text); + font-family: ui-sans-serif, -apple-system, system-ui, 'Segoe UI', Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 22px; +} + +h1, +h2, +h3 { + margin: 0; + font-weight: 600; + letter-spacing: -0.01em; +} + +p { + margin: 0; +} + +button { + font: inherit; +} + +/* ---------- header ---------- */ + +.app-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + max-width: 1120px; + margin: 0 auto; + padding: 24px 0 20px; + border-bottom: 1px solid var(--mcode-border); +} + +.app-header h1 { + font-size: 18px; + line-height: 28px; +} + +.app-header__desc { + margin-top: 4px; + max-width: 640px; + font-size: 13px; + line-height: 20px; + color: var(--mcode-text-muted); +} + +.app-header__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +/* ---------- layout ---------- */ + +main { + max-width: 1120px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 24px; + padding-top: 24px; +} + +.kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.kpi { + display: flex; + flex-direction: column; + gap: 4px; + padding: 16px 18px; + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: 12px; +} + +.kpi__label { + font-size: 12px; + line-height: 16px; + color: var(--mcode-text-muted); +} + +.kpi__value { + font-size: 24px; + line-height: 30px; + font-weight: 600; +} + +.kpi__value--warn { + color: var(--mcode-warning); +} + +.kpi__value--danger { + color: var(--mcode-danger); +} + +/* ---------- panels ---------- */ + +.panel { + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: 12px; + overflow: hidden; +} + +.panel__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid var(--mcode-border); +} + +.panel__head h2 { + font-size: 16px; + line-height: 24px; +} + +.panel__hint { + font-size: 12px; + line-height: 16px; + color: var(--mcode-text-muted); +} + +/* ---------- server rows ---------- */ + +.servers { + display: flex; + flex-direction: column; +} + +.server { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px 20px; + align-items: start; + padding: 16px 18px; + border-bottom: 1px solid var(--mcode-border); +} + +.server:last-child { + border-bottom: none; +} + +.server:hover { + background: var(--mcode-surface-muted); +} + +.server__name { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; +} + +/* The badge is swapped in place; the slot keeps the title layout stable. */ +.badge-slot { + display: inline-flex; + align-items: center; +} + +.server__url { + margin-top: 2px; + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + line-height: 18px; + color: var(--mcode-text-muted); + word-break: break-all; +} + +.server__meta { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + margin-top: 8px; + font-size: 12px; + line-height: 18px; + color: var(--mcode-text-muted); +} + +.server__meta code { + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.server__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; + align-items: center; +} + +.server__note { + font-size: 12px; + line-height: 18px; + color: var(--mcode-text-subtle); +} + +.server__warn { + font-size: 12px; + line-height: 18px; + color: var(--mcode-warning); +} + +/* ---------- badges ---------- */ + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 10px; + border-radius: 999px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface-muted); + font-size: 12px; + line-height: 18px; + color: var(--mcode-text-muted); + white-space: nowrap; +} + +.badge__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.badge--ok { + color: var(--mcode-success); + border-color: color-mix(in srgb, var(--mcode-success) 35%, transparent); +} + +.badge--warn { + color: var(--mcode-warning); + border-color: color-mix(in srgb, var(--mcode-warning) 35%, transparent); +} + +.badge--danger { + color: var(--mcode-danger); + border-color: color-mix(in srgb, var(--mcode-danger) 35%, transparent); +} + +.badge--info { + color: var(--mcode-accent); + border-color: color-mix(in srgb, var(--mcode-accent) 35%, transparent); +} + +/* ---------- buttons ---------- */ + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 0 12px; + border-radius: 8px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface-muted); + color: var(--mcode-text); + font-size: 13px; + line-height: 20px; + cursor: pointer; + transition: background-color 150ms ease-out, border-color 150ms ease-out, opacity 150ms ease-out; +} + +.btn:hover:not(:disabled) { + border-color: var(--mcode-border-strong); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* .btn sets display:inline-flex, which would otherwise beat [hidden]. */ +.btn[hidden] { + display: none; +} + +.btn:focus-visible { + outline: 2px solid var(--mcode-accent); + outline-offset: 2px; +} + +.btn--primary { + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); + border-color: transparent; +} + +.btn--secondary { + background: transparent; +} + +.btn--danger { + background: transparent; + color: var(--mcode-danger); + border-color: color-mix(in srgb, var(--mcode-danger) 40%, transparent); +} + +.btn--link { + background: transparent; + border-color: transparent; + color: var(--mcode-accent); + padding: 0 4px; + min-height: 24px; +} + +.btn--icon { + min-height: 32px; + width: 32px; + padding: 0; + justify-content: center; +} + +/* ---------- states ---------- */ + +.state { + padding: 32px 18px; + text-align: center; + color: var(--mcode-text-muted); + font-size: 13px; +} + +.state--error { + color: var(--mcode-danger); +} + +.state__title { + font-size: 14px; + font-weight: 600; + color: var(--mcode-text); + margin-bottom: 4px; +} + +.spinner { + width: 14px; + height: 14px; + border: 2px solid var(--mcode-border); + border-top-color: var(--mcode-accent); + border-radius: 50%; + animation: spin 700ms linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } + + .btn { + transition: none; + } +} + +/* ---------- modal ---------- */ + +.modal-backdrop { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 24px; + background: rgb(10 10 10 / 45%); + z-index: 20; +} + +.modal { + width: min(560px, 100%); + max-height: 84vh; + overflow: auto; + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: 12px; + box-shadow: 0 0 20px rgb(10 10 10 / 8%); +} + +.modal__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid var(--mcode-border); +} + +.modal__head h2 { + font-size: 16px; + line-height: 24px; +} + +.modal__body { + padding: 18px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.modal__foot { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 18px; + border-top: 1px solid var(--mcode-border); +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.field label { + font-size: 12px; + line-height: 16px; + color: var(--mcode-text-muted); +} + +.field input, +.field textarea { + width: 100%; + min-height: 36px; + padding: 6px 10px; + border-radius: 8px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface-muted); + color: var(--mcode-text); + font: inherit; + font-size: 13px; +} + +.field textarea { + min-height: 72px; + resize: vertical; + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +.field input:focus-visible, +.field textarea:focus-visible { + outline: 2px solid var(--mcode-accent); + outline-offset: 1px; + border-color: var(--mcode-accent); +} + +.field__hint { + font-size: 12px; + line-height: 18px; + color: var(--mcode-text-subtle); +} + +.field__error { + font-size: 12px; + line-height: 18px; + color: var(--mcode-danger); +} + +.steps { + margin: 0; + padding-left: 18px; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + line-height: 20px; + color: var(--mcode-text-muted); +} + +.steps li[data-state='done'] { + color: var(--mcode-success); +} + +.steps li[data-state='active'] { + color: var(--mcode-text); + font-weight: 600; +} + +.kv { + display: grid; + grid-template-columns: 132px minmax(0, 1fr); + gap: 6px 12px; + font-size: 13px; + line-height: 20px; +} + +.kv dt { + color: var(--mcode-text-muted); +} + +.kv dd { + margin: 0; + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + word-break: break-all; +} + +.notice { + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface-muted); + font-size: 13px; + line-height: 20px; +} + +.notice--warn { + border-color: color-mix(in srgb, var(--mcode-warning) 40%, transparent); + color: var(--mcode-warning); +} + +.notice--danger { + border-color: color-mix(in srgb, var(--mcode-danger) 40%, transparent); + color: var(--mcode-danger); +} + +.notice--ok { + border-color: color-mix(in srgb, var(--mcode-success) 40%, transparent); + color: var(--mcode-success); +} + +/* ---------- toast ---------- */ + +.toast-root { + position: fixed; + /* Anchored bottom-left on purpose: row actions are right-aligned, and a + toast sitting on top of them makes the next click land on another row. */ + left: 24px; + bottom: 24px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 30; + /* Toasts are informational and float over the list. They must never + intercept a click on the buttons underneath them. */ + pointer-events: none; +} + +.toast { + padding: 10px 14px; + border-radius: 8px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface); + box-shadow: 0 0 20px rgb(10 10 10 / 8%); + font-size: 13px; + line-height: 20px; + max-width: 360px; + pointer-events: none; +} + +.toast--error { + border-color: color-mix(in srgb, var(--mcode-danger) 45%, transparent); + color: var(--mcode-danger); +} + +.toast--ok { + border-color: color-mix(in srgb, var(--mcode-success) 45%, transparent); + color: var(--mcode-success); +} + +/* ---------- responsive ---------- */ + +@media (max-width: 768px) { + body { + padding: 0 16px 32px; + } + + .app-header { + flex-direction: column; + align-items: stretch; + } + + .kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .server { + grid-template-columns: minmax(0, 1fr); + } + + .server__actions { + justify-content: flex-start; + } + + .kv { + grid-template-columns: minmax(0, 1fr); + gap: 2px 0; + } + + .kv dt { + margin-top: 8px; + } +} diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/miniapp.json b/plugins/microbiosis/mcp-oauth-console/miniapp/miniapp.json new file mode 100644 index 00000000..11b3a586 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/miniapp.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": [ + "./miniapp/client" + ], + "node": [ + "./miniapp/node" + ] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/console" + }, + "mcpEndpoints": [] +} diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/node/miniapp-api.ts b/plugins/microbiosis/mcp-oauth-console/miniapp/node/miniapp-api.ts new file mode 100644 index 00000000..7a0e0fd3 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/node/miniapp-api.ts @@ -0,0 +1,109 @@ +/** + * Agent-facing Mini App runtime authoring declarations. + * + * Copy this file into a generated plugin for type checking. It contains no Host implementation; + * the Host injects runtime values through start(context). + * Keep the .ts filename: Electron packaging excludes .d.ts files from dependency assets. + */ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; +export type JsonObject = { readonly [key: string]: JsonValue }; + +declare const HOST_CONNECTOR_TOOL_REF: unique symbol; +export type HostConnectorToolRef = string & { + readonly [HOST_CONNECTOR_TOOL_REF]: 'HostConnectorToolRef'; +}; + +export interface HostConnectorTool { + readonly toolRef: HostConnectorToolRef; + readonly provider: string; + readonly name: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly outputSchema?: JsonValue; +} + +export interface HostConnectorListResult { + readonly tools: readonly HostConnectorTool[]; + readonly partial: boolean; +} + +export interface HostConnectorCallOptions { + readonly signal?: AbortSignal; +} + +export interface HostConnectorCallResult { + readonly invocationId: string; + /** + * Raw provider result; it is not normalized by the Host and may be an object, array, or primitive. + * A single text-block array is one provider shape, not a global Host transport contract. + * Decode only a probe-observed envelope; preserve every other value, including direct strings. + */ + readonly value: JsonValue; +} + +export interface HostConnectorClient { + /** Candidate-safe inventory only; available before and after activation. */ + list(options?: HostConnectorCallOptions): Promise; + /** Activation-only business dispatch; call from request handling, never start(context). */ + call( + toolRef: HostConnectorToolRef, + arguments_: JsonObject, + options?: HostConnectorCallOptions, + ): Promise; +} + +export type HostConnectorErrorDisposition = + | 'not_dispatched' + | 'provider_reported' + | 'unknown_after_dispatch'; + +export type HostConnectorErrorCode = + | 'TOOL_REF_STALE' + | 'SERVICE_RESTARTED' + | 'REQUEST_CANCELLED' + | 'CONNECTOR_TIMEOUT' + | 'INVALID_ARGUMENTS' + | 'CONNECTOR_PROVIDER_ERROR' + | 'CONNECTOR_UNAVAILABLE' + | 'CONNECTOR_OUTCOME_UNKNOWN'; + +export interface HostConnectorError extends Error { + readonly code: HostConnectorErrorCode; + readonly disposition: HostConnectorErrorDisposition; + readonly retryable: boolean; + readonly invocationId?: string; + readonly diagnostic?: { + readonly issues: readonly { + readonly path: string; + readonly constraint: string; + readonly limit?: number; + }[]; + }; +} + +export interface MiniAppLogger { + debug(message: string, fields?: JsonObject): void; + info(message: string, fields?: JsonObject): void; + warn(message: string, fields?: JsonObject): void; + error(message: string, fields?: JsonObject): void; +} + +export interface MiniAppLifecycle { + dispose(): void | Promise; +} + +export interface MiniAppContext { + readonly pluginId: string; + readonly pluginRoot: string; + readonly dataDir: string; + readonly listen: Readonly<{ readonly host: '127.0.0.1'; readonly port: number }>; + readonly signal: AbortSignal; + readonly logger: MiniAppLogger; + readonly hostConnector?: HostConnectorClient; +} + +export interface MiniAppModule { + /** Resolve only after the listener accepts connections and every route is installed. */ + start(context: MiniAppContext): Promise; +} diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs b/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs new file mode 100644 index 00000000..c617388b --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs @@ -0,0 +1,1101 @@ +// @ts-check + +/** + * MCP OAuth domain service. + * + * This module owns every state mutation of the board: OAuth metadata discovery, + * dynamic client registration, the PKCE authorization-code flow, credential + * storage, and the surgical `mcp.json` patch. + * + * Token material never leaves this module. The Client only ever receives + * metadata about a token (scope, expiry, fingerprint, status), never the token. + */ + +import { spawn } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; + +import { serviceError } from './errors.mjs'; + +const CLIENT_NAME = 'MCP OAuth Console'; +const CALLBACK_PATH = '/oauth/callback'; +const REFRESH_WINDOW_MS = 30 * 60 * 1000; +/** How long a successful detection stays valid, and how long a failed one is + * remembered before a retry is allowed (a flaky network must not pin a wrong + * conclusion for ten minutes). */ +const DISCOVERY_TTL_MS = 10 * 60 * 1000; +const FAILED_DISCOVERY_TTL_MS = 60 * 1000; +const PROBE_TIMEOUT_MS = 20_000; +const GRANT_TTL_MS = 30 * 60 * 1000; + +/** Short, non-reversible label for a token. Lets the user compare old vs new. */ +export function fingerprint(value) { + return createHash('sha256').update(String(value)).digest('hex').slice(0, 8); +} + +function base64url(input) { + return Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** Reads `exp` (ms epoch) from a JWT access token, when the token is a JWT. */ +export function readTokenExpiry(token) { + if (typeof token !== 'string') return null; + const parts = token.split('.'); + if (parts.length < 2) return null; + try { + const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); + if (typeof payload.exp === 'number') return payload.exp * 1000; + } catch { + return null; + } + return null; +} + +/* ------------------------------------------------------------------ */ +/* JSON text helpers - used to patch one field without reformatting */ +/* ------------------------------------------------------------------ */ + +function skipWhitespace(text, index) { + let i = index; + while (i < text.length && /\s/.test(text[i])) i += 1; + return i; +} + +function readJsonString(text, index) { + if (text[index] !== '"') throw new Error('expected_string'); + let i = index + 1; + let value = ''; + while (i < text.length) { + const ch = text[i]; + if (ch === '\\') { + const next = text[i + 1]; + const simple = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' }; + if (next === 'u') { + value += String.fromCharCode(Number.parseInt(text.slice(i + 2, i + 6), 16)); + i += 6; + continue; + } + value += simple[next] ?? next; + i += 2; + continue; + } + if (ch === '"') return { value, end: i + 1 }; + value += ch; + i += 1; + } + throw new Error('unterminated_string'); +} + +function matchObjectSpan(text, start) { + let depth = 0; + let i = start; + while (i < text.length) { + const ch = text[i]; + if (ch === '"') { + i = readJsonString(text, i).end; + continue; + } + if (ch === '{' || ch === '[') depth += 1; + else if (ch === '}' || ch === ']') { + depth -= 1; + if (depth === 0) return i + 1; + } + i += 1; + } + throw new Error('unterminated_object'); +} + +function findMemberObjectSpan(text, key, from = 0, to = text.length) { + const needle = JSON.stringify(key); + let index = text.indexOf(needle, from); + while (index !== -1 && index < to) { + let i = skipWhitespace(text, index + needle.length); + if (text[i] === ':') { + i = skipWhitespace(text, i + 1); + if (text[i] === '{') { + const end = matchObjectSpan(text, i); + return { start: i, end }; + } + } + index = text.indexOf(needle, index + 1); + } + return null; +} + +function findMemberStringSpan(text, key, from, to) { + const needle = JSON.stringify(key); + let index = text.indexOf(needle, from); + while (index !== -1 && index < to) { + let i = skipWhitespace(text, index + needle.length); + if (text[i] === ':') { + i = skipWhitespace(text, i + 1); + if (text[i] === '"') { + const parsed = readJsonString(text, i); + return { start: i, end: parsed.end, value: parsed.value }; + } + } + index = text.indexOf(needle, index + 1); + } + return null; +} + +/* ------------------------------------------------------------------ */ +/* Configuration + credential files */ +/* ------------------------------------------------------------------ */ + +/** + * Locates the runtime `mcp.json`. The Host data dir normally lives inside the + * MiniMax data dir, but the lookup walks up from several roots so a different + * data-dir layout still resolves. + */ +export function resolveMcpConfigPath(startDir) { + const override = process.env.MINIMAX_MCP_CONFIG; + if (override && existsSync(override)) return resolve(override); + + const roots = [ + startDir, + process.cwd(), + dirname(process.execPath), + ].filter(Boolean); + + for (const root of roots) { + let dir = resolve(root); + for (let depth = 0; depth < 10; depth += 1) { + const candidate = join(dir, 'mcp.json'); + if (existsSync(candidate)) return candidate; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + } + + return join(homedir(), '.minimax', 'mcp.json'); +} + +async function readConfigFile(path) { + const text = await readFile(path, 'utf8'); + const config = JSON.parse(text); + const servers = config && typeof config.mcpServers === 'object' && config.mcpServers ? config.mcpServers : {}; + return { text, config, servers }; +} + +async function writeJsonFile(path, value) { + await mkdir(dirname(path), { recursive: true }); + await writeFileAtomic(path, `${JSON.stringify(value, null, 2)}\n`); +} + +async function backupConfig(path, backupPath) { + try { + const text = await readFile(path, 'utf8'); + await mkdir(dirname(backupPath), { recursive: true }); + await writeFileAtomic(backupPath, text); + return true; + } catch { + return false; + } +} + +/** + * Writes a file atomically: a temp file in the same directory, then a rename. + * The original file mode is carried over, because the temp file starts with + * the default mode and the file being replaced may be restricted. + */ +async function writeFileAtomic(path, text) { + const dir = dirname(path); + const temp = join(dir, `.${basename(path)}.${process.pid}.${Date.now()}.tmp`); + const mode = await stat(path).then((info) => info.mode).catch(() => undefined); + try { + await writeFile(temp, text, 'utf8'); + if (mode !== undefined) await chmod(temp, mode).catch(() => undefined); + await rename(temp, path); + } catch (error) { + await rm(temp, { force: true }).catch(() => undefined); + throw error; + } +} + +/* ------------------------------------------------------------------ */ +/* OAuth protocol helpers */ +/* ------------------------------------------------------------------ */ + +function delay(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * HTTP helper with retry. The built-in fetch fails with a 10s connect timeout + * on this network's proxied first connection, so transient connect failures are + * retried instead of being reported as an empty or failed probe. + */ +async function httpJson(url, init, timeoutMs = PROBE_TIMEOUT_MS, attempts = 3, headersOnly = false) { + let lastError = null; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + if (headersOnly) { + // The body of an SSE stream never ends, so it must not be read. + try { + await response.body?.cancel(); + } catch { + // the stream was already closed + } + return { status: response.status, ok: response.ok, headers: response.headers, text: '', json: null }; + } + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + return { status: response.status, ok: response.ok, headers: response.headers, text, json }; + } catch (error) { + lastError = error; + if (attempt < attempts) await delay(500 * attempt); + } finally { + clearTimeout(timer); + } + } + throw lastError instanceof Error ? lastError : new Error('request_failed'); +} + +/** + * The MCP request used to test an endpoint: a real `initialize` handshake. + * A plain GET is not a valid MCP call, and servers answer it with 405 before + * any auth check, which would make an OAuth-protected endpoint look open. + */ +function buildInitializeRequest(token) { + const headers = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }; + if (token) headers.authorization = `Bearer ${token}`; + return { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: CLIENT_NAME, version: '1.0.0' }, + }, + }), + }; +} + +/** + * The request used to test an endpoint. It must match the declared transport: + * - streamable-http: a real `initialize` JSON-RPC call; + * - sse: a GET that opens the event stream. A POST to the /sse URL is not a + * valid call for that transport and answers 405. + */ +function probeRequest(url, { transport, token }) { + if (transport === 'sse') { + const headers = { accept: 'text/event-stream' }; + if (token) headers.authorization = `Bearer ${token}`; + return { method: 'GET', headers }; + } + return buildInitializeRequest(token); +} + +/** + * Validates a target before the process makes a request to it. The page is + * untrusted input to the Node process, so a typed URL must not turn this Mini + * App into an open proxy. + */ +export function assertTargetUrl(url) { + let parsed; + try { + parsed = new URL(url); + } catch { + throw serviceError('target_url_invalid'); + } + if (parsed.username || parsed.password) throw serviceError('target_url_contains_credentials'); + const host = parsed.hostname.toLowerCase(); + const loopback = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; + if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) { + throw serviceError('target_url_invalid'); + } + // Instance metadata and link-local endpoints are never MCP servers. + if (host === '169.254.169.254' || host.endsWith('.internal') || host.startsWith('169.254.')) { + throw serviceError('target_url_blocked'); + } + return parsed; +} + +/** + * Detects what an MCP endpoint demands and resolves the authorization server + * metadata via the RFC 9728 protected-resource document. + * + * A 401 alone does NOT mean OAuth: the server may want an API key or another + * scheme. Only a `WWW-Authenticate: Bearer resource_metadata="..."` challenge + * is treated as a declaration of OAuth. + */ +export async function discoverAuthorization(url, options = {}) { + const transport = options.transport === 'sse' ? 'sse' : 'streamable-http'; + const probe = await httpJson(url, probeRequest(url, { transport }), PROBE_TIMEOUT_MS, 3, transport === 'sse'); + if (probe.status !== 401) { + // Only a 2xx proves the endpoint accepted an unauthenticated handshake. + // 404 / 405 / 5xx say nothing about whether auth is required. + if (probe.status >= 200 && probe.status < 300) { + return { oauthProtected: false, authRequirement: 'none', status: probe.status, metadata: null, authorizationServer: null }; + } + return { + oauthProtected: null, + authRequirement: 'unknown', + status: probe.status, + metadata: null, + authorizationServer: null, + reason: 'probe_indeterminate', + }; + } + const challenge = probe.headers.get('www-authenticate') || ''; + const resourceMatch = /resource_metadata="([^"]+)"/i.exec(challenge); + if (!resourceMatch) { + // No OAuth challenge: the endpoint rejected us, but it never said which + // scheme it wants. Claiming "OAuth" here would be an over-assertion. + const bearer = /^bearer/i.test(challenge.trim()); + return { + oauthProtected: false, + authRequirement: bearer ? 'bearer' : 'credential', + status: 401, + metadata: null, + authorizationServer: null, + reason: bearer ? 'bearer_without_resource_metadata' : 'credential_required_unknown_scheme', + }; + } + const resourceDoc = await httpJson(resourceMatch[1], { headers: { accept: 'application/json' } }); + const issuers = Array.isArray(resourceDoc.json?.authorization_servers) ? resourceDoc.json.authorization_servers : []; + const metadata = await loadAuthorizationServerMetadata(issuers); + return { + oauthProtected: true, + authRequirement: 'oauth', + status: 401, + metadata, + authorizationServer: metadata?.issuer ?? issuers[0] ?? null, + scopes: metadata?.scopes_supported ?? [], + resource: resourceDoc.json?.resource ?? null, + }; +} + +async function loadAuthorizationServerMetadata(issuers) { + for (const issuer of issuers) { + const base = String(issuer).replace(/\/+$/, ''); + for (const candidate of [`${base}/.well-known/oauth-authorization-server`, `${base}/.well-known/openid-configuration`]) { + try { + const response = await httpJson(candidate, { headers: { accept: 'application/json' } }); + if (response.ok && response.json?.token_endpoint) return response.json; + } catch { + // try the next well-known location + } + } + } + return null; +} + +function pickAuthMethod(metadata) { + const supported = Array.isArray(metadata?.token_endpoint_auth_methods_supported) + ? metadata.token_endpoint_auth_methods_supported + : []; + if (supported.includes('client_secret_basic')) return 'client_secret_basic'; + if (supported.includes('client_secret_post')) return 'client_secret_post'; + return 'client_secret_post'; +} + +async function registerClient(metadata, { redirectUri, scope }) { + const endpoint = metadata?.registration_endpoint; + if (!endpoint) throw serviceError('dynamic_registration_unsupported'); + const supported = Array.isArray(metadata.grant_types_supported) ? metadata.grant_types_supported : []; + const grantTypes = ['authorization_code']; + if (supported.includes('refresh_token') || supported.length === 0) grantTypes.push('refresh_token'); + const body = { + client_name: CLIENT_NAME, + redirect_uris: [redirectUri], + grant_types: grantTypes, + response_types: ['code'], + token_endpoint_auth_method: pickAuthMethod(metadata), + }; + if (scope) body.scope = scope; + const response = await httpJson(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify(body), + }); + if (!response.ok || !response.json?.client_id) { + throw serviceError(`dynamic_registration_failed_${response.status}`); + } + return response.json; +} + +function buildAuthorizeUrl(metadata, { clientId, redirectUri, state, codeChallenge, scope }) { + const endpoint = metadata?.authorization_endpoint; + if (!endpoint) throw serviceError('authorization_endpoint_missing'); + const url = new URL(endpoint); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('state', state); + url.searchParams.set('code_challenge', codeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + if (scope) url.searchParams.set('scope', scope); + return url.toString(); +} + +function tokenRequestBody({ grantType, code, codeVerifier, redirectUri, refreshToken, clientId, clientSecret, authMethod }) { + const form = new URLSearchParams(); + form.set('grant_type', grantType); + if (code) form.set('code', code); + if (codeVerifier) form.set('code_verifier', codeVerifier); + if (redirectUri) form.set('redirect_uri', redirectUri); + if (refreshToken) form.set('refresh_token', refreshToken); + if (clientId) form.set('client_id', clientId); + if (clientSecret && authMethod === 'client_secret_post') form.set('client_secret', clientSecret); + return form; +} + +async function requestToken(metadata, client, form) { + const headers = { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }; + if (client.client_secret && client.auth_method === 'client_secret_basic') { + headers.authorization = `Basic ${Buffer.from(`${client.client_id}:${client.client_secret}`).toString('base64')}`; + } + const response = await httpJson(metadata.token_endpoint, { + method: 'POST', + headers, + body: form.toString(), + }); + if (!response.ok || !response.json?.access_token) { + const code = response.json?.error || `http_${response.status}`; + throw serviceError(`token_request_failed_${code}`); + } + return response.json; +} + +/** + * Verifies a token against the MCP endpoint with a handshake that matches the + * declared transport. + */ +export async function probeMcpEndpoint(url, token, transport) { + const isSse = transport === 'sse'; + try { + const response = await httpJson(url, probeRequest(url, { transport, token }), PROBE_TIMEOUT_MS, 3, isSse); + return { status: response.status, ok: response.status === 200 || response.status === 204 }; + } catch (error) { + return { status: 0, ok: false, error: error instanceof Error ? error.message : 'probe_failed' }; + } +} + +/* ------------------------------------------------------------------ */ +/* Service */ +/* ------------------------------------------------------------------ */ + +export function createOAuthService({ dataDir, baseUrl, logger }) { + const mcpConfigPath = resolveMcpConfigPath(dataDir); + // Durable state belongs in the Host-owned dataDir. A credential directory + // left next to mcp.json by an earlier version is read once and imported; + // it is never written to again. + const credentialDir = join(dataDir, 'mcp-credentials'); + const legacyCredentialDir = join(dirname(mcpConfigPath), 'mcp-credentials'); + const backupPath = join(dataDir, 'mcp.json.bak'); + + /** sessionId -> pending authorization state (server side only) */ + const pending = new Map(); + /** serverName -> discovery cache */ + const discoveryCache = new Map(); + /** serverName -> pending grant awaiting user confirmation */ + const awaitingConfirmation = new Map(); + + const children = new Set(); + + function log(level, message) { + // Only the keys of `fields` leave the process, so diagnostics belong in + // the message itself. + logger[level](message); + } + + /** Serializes writes to the configuration file so two of them cannot race. */ + let writeChain = Promise.resolve(); + const withWriteLock = (task) => { + const run = writeChain.then(task, task); + writeChain = run.then( + () => undefined, + () => undefined, + ); + return run; + }; + + async function listConfiguredServers() { + let parsed; + try { + parsed = await readConfigFile(mcpConfigPath); + } catch (error) { + return { servers: [], error: error instanceof Error ? error.message : 'config_unreadable' }; + } + const servers = Object.entries(parsed.servers).map(([name, entry]) => { + const headers = entry && typeof entry === 'object' && entry.headers && typeof entry.headers === 'object' ? entry.headers : {}; + const authorization = typeof headers.Authorization === 'string' ? headers.Authorization : ''; + const scheme = authorization ? authorization.split(/\s+/)[0] : ''; + const tokenPart = scheme ? authorization.slice(scheme.length).trim() : ''; + const otherHeaderCount = Object.keys(headers).filter((key) => key !== 'Authorization').length; + return { + name, + type: typeof entry?.type === 'string' ? entry.type : '', + url: typeof entry?.url === 'string' ? entry.url : '', + enabled: entry?.enabled !== false, + hasAuthorization: Boolean(authorization), + authorizationScheme: scheme, + otherHeaderCount, + // Fingerprint only: the page never sees the credential itself. + authorizationFingerprint: tokenPart ? fingerprint(tokenPart) : null, + }; + }); + return { servers, error: null }; + } + + async function loadCredential(serverName) { + const own = join(credentialDir, `${serverName}.json`); + if (existsSync(own)) { + try { + return JSON.parse(await readFile(own, 'utf8')); + } catch { + return null; + } + } + // One-time import from the legacy directory: read-only, and the copy that + // lands in dataDir becomes the only copy this app maintains. + const legacy = join(legacyCredentialDir, `${serverName}.json`); + if (!existsSync(legacy)) return null; + try { + const data = JSON.parse(await readFile(legacy, 'utf8')); + await saveCredential(serverName, data); + log('info', `oauth.credential_imported server=${serverName}`); + return data; + } catch { + return null; + } + } + + async function saveCredential(serverName, data) { + const path = join(credentialDir, `${serverName}.json`); + await writeJsonFile(path, data); + return path; + } + + async function probeDiscovery(serverName, url, transport) { + const cached = discoveryCache.get(serverName); + if (cached && Date.now() - cached.at < cached.ttlMs) return cached.value; + try { + const value = await discoverAuthorization(url, { transport }); + discoveryCache.set(serverName, { at: Date.now(), ttlMs: DISCOVERY_TTL_MS, value }); + return value; + } catch (error) { + // A failed probe means "unknown", not "no OAuth required". Reporting it as + // `oauthProtected: false` would tell the page the endpoint is open. + const value = { + oauthProtected: null, + authRequirement: 'unknown', + status: 0, + metadata: null, + authorizationServer: null, + scopes: [], + reason: 'probe_failed', + error: error instanceof Error ? error.message : 'probe_failed', + }; + discoveryCache.set(serverName, { at: Date.now(), ttlMs: FAILED_DISCOVERY_TTL_MS, value }); + return value; + } + } + + /** Public, credential-free snapshot for the Client. */ + async function buildState() { + const { servers, error: configError } = await listConfiguredServers(); + const items = []; + for (const server of servers) { + const credential = await loadCredential(server.name); + const cachedDiscovery = discoveryCache.get(server.name); + const expiresAt = + credential?.access_token + ? readTokenExpiry(credential.access_token) ?? + (credential.obtained_at && credential.expires_in + ? Date.parse(credential.obtained_at) + Number(credential.expires_in) * 1000 + : null) + : null; + const grant = awaitingConfirmation.get(server.name); + const credentialFingerprint = credential?.access_token ? fingerprint(credential.access_token) : null; + const configFingerprint = server.authorizationFingerprint; + + // Two orthogonal facts, kept apart on purpose: + // * what the SERVER requires -> oauthProtected / authorizationServer + // * what THIS MACHINE holds -> credentialState + // A server can require OAuth and still be satisfied by a local token, and + // a machine can hold a token that the configuration does not actually use. + const warnings = []; + if (credential?.access_token && !server.hasAuthorization) { + warnings.push('凭据文件里有令牌,但 mcp.json 中未配置 Authorization,实际不会生效'); + } + if (server.hasAuthorization && credentialFingerprint && configFingerprint && credentialFingerprint !== configFingerprint) { + warnings.push('mcp.json 中的令牌与凭据文件不一致,续期后两者会同步'); + } + if (credential?.refresh_token && !credential?.client_id) { + warnings.push('凭据缺少 client_id,无法自动续期'); + } + + let credentialState; + if (grant) credentialState = 'pending'; + else if (!server.hasAuthorization) { + credentialState = server.otherHeaderCount > 0 ? 'other_scheme' : 'not_configured'; + } else if (Number.isFinite(expiresAt)) { + credentialState = expiresAt - Date.now() <= 0 ? 'expired' : expiresAt - Date.now() < REFRESH_WINDOW_MS ? 'expiring' : 'valid'; + } else credentialState = 'configured_unknown'; + + items.push({ + name: server.name, + type: server.type, + url: server.url, + enabled: server.enabled, + hasAuthorization: server.hasAuthorization, + authorizationScheme: server.authorizationScheme, + oauthProtected: cachedDiscovery?.value?.oauthProtected ?? null, + authRequirement: cachedDiscovery?.value?.authRequirement ?? 'unknown', + authorizationServer: cachedDiscovery?.value?.authorizationServer ?? null, + supportedScopes: cachedDiscovery?.value?.scopes ?? [], + dynamicRegistration: Boolean(cachedDiscovery?.value?.metadata?.registration_endpoint), + oauthReason: cachedDiscovery?.value?.reason ?? null, + scope: credential?.scope ?? null, + expiresAt: Number.isFinite(expiresAt) ? expiresAt : null, + obtainedAt: credential?.obtained_at ?? null, + remainingMs: Number.isFinite(expiresAt) ? expiresAt - Date.now() : null, + refreshable: Boolean(credential?.refresh_token && credential?.client_id), + credentialState, + configFingerprint, + tokenFingerprint: credentialFingerprint, + warnings, + pendingGrant: Boolean(grant), + pendingFingerprint: grant ? fingerprint(grant.token.access_token) : null, + lastError: grant?.error ?? null, + }); + } + const now = Date.now(); + return { + now, + configError, + servers: items, + summary: { + total: items.length, + authorized: items.filter((item) => item.hasAuthorization).length, + refreshable: items.filter((item) => item.refreshable).length, + expiringSoon: items.filter((item) => Number.isFinite(item.remainingMs) && item.remainingMs > 0 && item.remainingMs < REFRESH_WINDOW_MS).length, + }, + }; + } + + function openInBrowser(url) { + try { + // Arguments are passed as an argv array and never through a shell. + const isWindows = process.platform === 'win32'; + const child = isWindows + ? spawn('explorer.exe', [url], { stdio: 'ignore' }) + : spawn('open', [url], { stdio: 'ignore' }); + children.add(child); + child.once('exit', () => children.delete(child)); + child.unref(); + return true; + } catch { + return false; + } + } + + async function startAuthorization({ serverName, url, scope }) { + const { servers } = await listConfiguredServers(); + const existing = servers.find((item) => item.name === serverName); + const targetUrl = existing?.url || url; + if (!targetUrl) throw serviceError('target_url_required'); + assertTargetUrl(targetUrl); + + const transport = existing?.type === 'sse' ? 'sse' : 'streamable-http'; + const discovery = await probeDiscovery(serverName, targetUrl, transport); + if (discovery.authRequirement !== 'oauth' || !discovery.metadata) { + // The endpoint never declared OAuth, so there is no authorization server + // to run a browser flow against. Say exactly what it wants instead. + if (discovery.reason === 'bearer_without_resource_metadata') throw serviceError('bearer_without_resource_metadata'); + if (discovery.reason === 'credential_required_unknown_scheme') throw serviceError('credential_required_unknown_scheme'); + throw serviceError('authorization_server_not_resolved'); + } + + const metadata = discovery.metadata; + const redirectUri = `${baseUrl}${CALLBACK_PATH}`; + const client = await registerClient(metadata, { redirectUri, scope }); + const codeVerifier = base64url(randomBytes(32)); + const codeChallenge = base64url(createHash('sha256').update(codeVerifier).digest()); + const state = randomBytes(16).toString('hex'); + const sessionId = randomBytes(12).toString('hex'); + + const resolvedScope = scope || discovery.scopes?.[0] || undefined; + const authorizationUrl = buildAuthorizeUrl(metadata, { + clientId: client.client_id, + redirectUri, + state, + codeChallenge, + scope: resolvedScope, + }); + + pending.set(sessionId, { + sessionId, + serverName, + url: targetUrl, + metadata, + client: { + client_id: client.client_id, + client_secret: client.client_secret ?? null, + auth_method: client.token_endpoint_auth_method ?? pickAuthMethod(metadata), + }, + codeVerifier, + redirectUri, + state, + scope: resolvedScope ?? null, + createdAt: Date.now(), + }); + + const opened = openInBrowser(authorizationUrl); + log('info', `oauth.authorization_started server=${serverName} browser_opened=${opened}`); + return { + sessionId, + authorizationUrl, + redirectUri, + scope: resolvedScope ?? null, + authorizationServer: metadata.issuer ?? null, + browserOpened: opened, + }; + } + + async function completeAuthorization({ sessionId, redirectUrl }) { + const session = pending.get(sessionId); + if (!session) throw serviceError('session_not_found'); + let code = null; + let state = null; + if (redirectUrl) { + const url = new URL(redirectUrl); + code = url.searchParams.get('code'); + state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + if (error) throw serviceError(`authorization_denied_${error}`); + } + if (!code) throw serviceError('authorization_code_missing'); + if (state && state !== session.state) throw serviceError('state_mismatch'); + + const form = tokenRequestBody({ + grantType: 'authorization_code', + code, + codeVerifier: session.codeVerifier, + redirectUri: session.redirectUri, + clientId: session.client.client_id, + clientSecret: session.client.client_secret, + authMethod: session.client.auth_method, + }); + const token = await requestToken(session.metadata, session.client, form); + pending.delete(sessionId); + + const grant = { + serverName: session.serverName, + url: session.url, + token, + client: session.client, + scope: session.scope, + createdAt: Date.now(), + }; + awaitingConfirmation.set(session.serverName, grant); + log('info', `oauth.authorization_completed server=${session.serverName}`); + return { + serverName: session.serverName, + fingerprint: fingerprint(token.access_token), + scope: token.scope ?? session.scope ?? null, + expiresIn: token.expires_in ?? null, + hasRefreshToken: Boolean(token.refresh_token), + }; + } + + /** Handles the browser redirect. The token stays in Node memory. */ + async function handleCallback(query) { + const state = query.get('state'); + const error = query.get('error'); + let session = null; + for (const candidate of pending.values()) { + if (candidate.state === state) { + session = candidate; + break; + } + } + if (!session) { + return { ok: false, message: '未找到对应的授权会话,请回到 Mini App 重新发起授权。' }; + } + if (error) { + pending.delete(session.sessionId); + return { ok: false, message: `授权被拒绝:${error}` }; + } + const code = query.get('code'); + if (!code) return { ok: false, message: '回调缺少 authorization code。' }; + try { + const form = tokenRequestBody({ + grantType: 'authorization_code', + code, + codeVerifier: session.codeVerifier, + redirectUri: session.redirectUri, + clientId: session.client.client_id, + clientSecret: session.client.client_secret, + authMethod: session.client.auth_method, + }); + const token = await requestToken(session.metadata, session.client, form); + pending.delete(session.sessionId); + awaitingConfirmation.set(session.serverName, { + serverName: session.serverName, + url: session.url, + token, + client: session.client, + scope: session.scope, + createdAt: Date.now(), + }); + log('info', `oauth.callback_received server=${session.serverName}`); + return { ok: true, serverName: session.serverName, message: '授权成功,请回到 Mini App 确认写入配置。' }; + } catch (tokenError) { + pending.delete(session.sessionId); + const message = tokenError instanceof Error ? tokenError.message : 'token_exchange_failed'; + log('warn', `oauth.callback_failed server=${session.serverName} reason=${message}`); + return { ok: false, message: `换取令牌失败:${message}` }; + } + } + + /** Commits a pending grant: credential file first, then the mcp.json patch. */ + async function applyGrant(serverName) { + const grant = awaitingConfirmation.get(serverName); + if (!grant) throw serviceError('no_pending_grant'); + const token = grant.token; + const expiresAt = readTokenExpiry(token.access_token); + const credential = { + server: serverName, + url: grant.url, + access_token: token.access_token, + refresh_token: token.refresh_token ?? null, + token_type: token.token_type ?? 'Bearer', + scope: token.scope ?? grant.scope ?? null, + expires_in: token.expires_in ?? null, + client_id: grant.client.client_id, + client_secret: grant.client.client_secret, + obtained_at: new Date().toISOString(), + note: 'Managed by MCP OAuth Console', + }; + await saveCredential(serverName, credential); + const patch = await patchMcpAuthorization(serverName, token.access_token, { url: grant.url }); + awaitingConfirmation.delete(serverName); + const { servers: configured } = await listConfiguredServers(); + const transport = configured.find((item) => item.name === serverName)?.type; + const probe = grant.url ? await probeMcpEndpoint(grant.url, token.access_token, transport) : null; + log('info', `oauth.grant_applied server=${serverName} probe=${probe?.status ?? 'n/a'}`); + return { + serverName, + fingerprint: fingerprint(token.access_token), + expiresAt, + created: patch.created, + replaced: patch.replaced, + previousFingerprint: patch.previousFingerprint, + probe, + }; + } + + /** Refreshes an existing token with the stored refresh token. */ + async function refreshServer(serverName) { + const credential = await loadCredential(serverName); + if (!credential?.refresh_token || !credential?.client_id) throw serviceError('refresh_token_unavailable'); + const { servers } = await listConfiguredServers(); + const server = servers.find((item) => item.name === serverName); + const url = server?.url || credential.url; + if (!url) throw serviceError('target_url_missing'); + + const discovery = await probeDiscovery(serverName, url, server?.type); + const metadata = discovery.metadata ?? (await loadAuthorizationServerMetadata([credential.url ?? ''])); + if (!metadata?.token_endpoint) throw serviceError('token_endpoint_unresolved'); + + const client = { + client_id: credential.client_id, + client_secret: credential.client_secret ?? null, + auth_method: 'client_secret_basic', + }; + let form = tokenRequestBody({ + grantType: 'refresh_token', + refreshToken: credential.refresh_token, + clientId: client.client_id, + clientSecret: client.client_secret, + authMethod: client.auth_method, + }); + let token; + try { + token = await requestToken(metadata, client, form); + } catch (error) { + form = tokenRequestBody({ + grantType: 'refresh_token', + refreshToken: credential.refresh_token, + clientId: client.client_id, + clientSecret: client.client_secret, + authMethod: 'client_secret_post', + }); + client.auth_method = 'client_secret_post'; + token = await requestToken(metadata, client, form); + } + + const next = { + ...credential, + access_token: token.access_token, + refresh_token: token.refresh_token ?? credential.refresh_token, + token_type: token.token_type ?? 'Bearer', + scope: token.scope ?? credential.scope, + expires_in: token.expires_in ?? null, + obtained_at: new Date().toISOString(), + }; + await saveCredential(serverName, next); + const patch = await patchMcpAuthorization(serverName, token.access_token, { url }); + const probe = await probeMcpEndpoint(url, token.access_token, server?.type); + log('info', `oauth.token_refreshed server=${serverName} probe=${probe.status}`); + return { + serverName, + fingerprint: fingerprint(token.access_token), + expiresAt: readTokenExpiry(token.access_token), + replaced: patch.replaced, + previousFingerprint: patch.previousFingerprint, + probe, + }; + } + + /** + * Patches `mcpServers..headers.Authorization` in place, keeping the + * rest of the file byte-identical. Creates the entry when it is missing. + */ + async function patchMcpAuthorization(serverName, token, { url } = {}) { + const bearer = `Bearer ${token}`; + const parsed = await readConfigFile(mcpConfigPath); + const existing = parsed.servers[serverName]; + + if (!existing) { + const next = { ...parsed.config }; + next.mcpServers = { + ...parsed.servers, + [serverName]: { + type: 'streamable-http', + enabled: true, + configured: true, + builtin: false, + url: url ?? '', + headers: { Authorization: bearer }, + }, + }; + return withWriteLock(async () => { + await backupConfig(mcpConfigPath, backupPath); + await writeJsonFile(mcpConfigPath, next); + return { created: true, replaced: false, previousFingerprint: null }; + }); + } + const headers = existing.headers && typeof existing.headers === 'object' ? existing.headers : null; + const previous = headers && typeof headers.Authorization === 'string' ? headers.Authorization : ''; + const span = findMemberObjectSpan(parsed.text, serverName); + if (!span) throw serviceError('server_entry_not_locatable'); + + let nextText; + let replaced = false; + const authSpan = findMemberStringSpan(parsed.text, 'Authorization', span.start, span.end); + if (authSpan) { + // Replace the existing Authorization value in place. + nextText = `${parsed.text.slice(0, authSpan.start)}${JSON.stringify(bearer)}${parsed.text.slice(authSpan.end)}`; + replaced = true; + } else { + // The entry carries no Authorization header yet - it may authenticate with + // x-api-key, or not at all. Add the header instead of refusing the write + // after the user already completed the browser authorization. + const headersSpan = headers ? findMemberObjectSpan(parsed.text, 'headers', span.start, span.end) : null; + if (headersSpan) { + const insertAt = headersSpan.start + 1; + nextText = `${parsed.text.slice(0, insertAt)}${JSON.stringify('Authorization')}: ${JSON.stringify(bearer)}, ${parsed.text.slice(insertAt)}`; + } else { + const insertAt = span.start + 1; + nextText = `${parsed.text.slice(0, insertAt)}${JSON.stringify('headers')}: { ${JSON.stringify('Authorization')}: ${JSON.stringify(bearer)} }, ${parsed.text.slice(insertAt)}`; + } + } + + let verified; + try { + verified = JSON.parse(nextText); + } catch { + throw serviceError('patched_config_invalid'); + } + if (verified?.mcpServers?.[serverName]?.headers?.Authorization !== bearer) { + throw serviceError('patch_verification_failed'); + } + return withWriteLock(async () => { + await backupConfig(mcpConfigPath, backupPath); + await writeFileAtomic(mcpConfigPath, nextText); + return { + created: false, + replaced, + previousFingerprint: previous ? fingerprint(previous.replace(/^\S+\s+/, '')) : null, + }; + }); + } + + /** Refreshes every server whose token is inside the refresh window. */ + async function refreshExpiring() { + const { servers } = await listConfiguredServers(); + const results = []; + for (const server of servers) { + const credential = await loadCredential(server.name); + if (!credential?.refresh_token || !credential?.client_id) continue; + const expiresAt = readTokenExpiry(credential.access_token); + if (!Number.isFinite(expiresAt)) continue; + if (expiresAt - Date.now() > REFRESH_WINDOW_MS) continue; + try { + const result = await refreshServer(server.name); + results.push({ server: server.name, ok: true, expiresAt: result.expiresAt }); + } catch (error) { + const message = error instanceof Error ? error.message : 'refresh_failed'; + results.push({ server: server.name, ok: false, reason: message }); + log('warn', `oauth.auto_refresh_failed server=${server.name} reason=${message}`); + } + } + return results; + } + + function dispose() { + for (const child of children) { + try { + child.kill(); + } catch { + // child already exited + } + } + children.clear(); + pending.clear(); + awaitingConfirmation.clear(); + discoveryCache.clear(); + } + + return { + mcpConfigPath, + credentialDir, + listConfiguredServers, + buildState, + probeDiscovery, + startAuthorization, + completeAuthorization, + handleCallback, + applyGrant, + writeAuthorization: patchMcpAuthorization, + refreshServer, + refreshExpiring, + dispose, + }; +} diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs b/plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs new file mode 100644 index 00000000..137e3421 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs @@ -0,0 +1,398 @@ +// @ts-check + +/** + * Mini App HTTP entry for the MCP OAuth console. + * + * Serves the Client surface plus a narrow business API. Every mutation is + * delegated to the OAuth domain service, which owns the credential file and + * the `mcp.json` patch. Nothing here echoes token material back to the page. + */ + +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { extname, join, normalize } from 'node:path'; + +import { createOAuthService } from './oauth-service.mjs'; +import { describeServiceError, isKnownServiceError } from './errors.mjs'; + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ +/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ + +const SURFACE_PATH = '/console'; +const CALLBACK_PATH = '/oauth/callback'; +const REFRESH_INTERVAL_MS = 60 * 1000; + +const CONTENT_TYPES = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', +}; + +/** + * @param {MiniAppContext} context + * @returns {Promise} + */ +export async function start(context) { + const clientDir = join(context.pluginRoot, 'miniapp', 'client'); + const baseUrl = `http://127.0.0.1:${context.listen.port}`; + const service = createOAuthService({ + dataDir: context.dataDir, + baseUrl, + logger: context.logger, + }); + + /** @type {Set} */ + const listeners = new Set(); + + const broadcast = (event, payload) => { + const frame = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`; + for (const response of listeners) { + try { + response.write(frame); + } catch { + listeners.delete(response); + } + } + }; + + const sendJson = (response, status, body) => { + const text = JSON.stringify(body); + response.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }); + response.end(text); + }; + + /** + * Resolves a domain error code through the shared catalog: the catalog owns + * the HTTP status and the user-facing text, so the Client never has to + * re-describe a code and a precise reason is never collapsed into a generic + * "internal error". + */ + const fail = (response, code) => { + const { status, message } = describeServiceError(code); + if (!isKnownServiceError(code)) { + context.logger.warn(`miniapp.request.unregistered_error_code code=${code}`); + } + sendJson(response, status, { ok: false, error: code, message }); + }; + + const readBody = async (request) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + if (!chunks.length) return {}; + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + return null; + } + }; + + const server = createServer((request, response) => { + void handle(request, response).catch((error) => { + const code = error instanceof Error ? error.message : 'internal_error'; + // Only the keys of `fields` leave the process, so the reason goes in the + // message itself. + context.logger.error(`miniapp.request.failed reason=${code}`); + if (!response.headersSent) fail(response, code); + else response.end(); + }); + }); + + async function handle(request, response) { + const url = new URL(request.url ?? '/', 'http://miniapp.local'); + const pathname = url.pathname; + const method = request.method ?? 'GET'; + + if (method === 'GET' && (pathname === SURFACE_PATH || pathname === `${SURFACE_PATH}/`)) { + const html = await readFile(join(clientDir, 'index.html')); + response.writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + }); + response.end(html); + return; + } + + if (method === 'GET' && pathname.startsWith('/assets/')) { + const name = normalize(pathname.slice('/assets/'.length)).replace(/^([/\\])+/, ''); + if (!name || name.includes('..') || name.includes('/') || name.includes('\\')) { + fail(response, 'not_found'); + return; + } + try { + const file = await readFile(join(clientDir, name)); + response.writeHead(200, { + 'content-type': CONTENT_TYPES[extname(name)] ?? 'application/octet-stream', + 'cache-control': 'no-store', + }); + response.end(file); + } catch { + fail(response, 'not_found'); + } + return; + } + + if (method === 'GET' && pathname === '/api/state') { + const state = await service.buildState(); + sendJson(response, 200, { ok: true, state }); + return; + } + + if (method === 'GET' && pathname === '/api/events') { + response.writeHead(200, { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-store', + connection: 'keep-alive', + }); + response.write('retry: 3000\n\n'); + listeners.add(response); + request.on('close', () => listeners.delete(response)); + return; + } + + if (method === 'GET' && pathname === CALLBACK_PATH) { + const result = await service.handleCallback(url.searchParams); + response.writeHead(result.ok ? 200 : 400, { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + }); + response.end(renderCallbackPage(result)); + broadcast('state', { reason: 'authorization' }); + return; + } + + if (method === 'POST' && pathname === '/api/discover') { + const body = await readBody(request); + if (!body) { + fail(response, 'invalid_json'); + return; + } + const { servers } = await service.listConfiguredServers(); + const server = servers.find((item) => item.name === body.serverName); + if (!server) { + fail(response, 'server_not_found'); + return; + } + if (!server.url) { + fail(response, 'server_without_url'); + return; + } + const discovery = await service.probeDiscovery(server.name, server.url, server.type); + sendJson(response, 200, { + ok: true, + discovery: { + oauthProtected: discovery.oauthProtected, + authRequirement: discovery.authRequirement ?? 'unknown', + status: discovery.status, + authorizationServer: discovery.authorizationServer, + scopes: discovery.scopes ?? [], + dynamicRegistration: Boolean(discovery.metadata?.registration_endpoint), + authorizationEndpoint: discovery.metadata?.authorization_endpoint ?? null, + tokenEndpoint: discovery.metadata?.token_endpoint ?? null, + reason: discovery.reason ?? null, + reasonMessage: discovery.reason ? describeServiceError(discovery.reason).message : null, + }, + }); + return; + } + + if (method === 'POST' && pathname === '/api/authorize') { + const body = await readBody(request); + if (!body) { + fail(response, 'invalid_json'); + return; + } + const result = await service.startAuthorization({ + serverName: String(body.serverName ?? ''), + url: body.url ? String(body.url) : undefined, + scope: body.scope ? String(body.scope) : undefined, + }); + sendJson(response, 200, { ok: true, ...result }); + return; + } + + if (method === 'POST' && pathname === '/api/authorize/complete') { + const body = await readBody(request); + if (!body) { + fail(response, 'invalid_json'); + return; + } + const result = await service.completeAuthorization({ + sessionId: String(body.sessionId ?? ''), + redirectUrl: body.redirectUrl ? String(body.redirectUrl) : undefined, + }); + broadcast('state', { reason: 'authorization' }); + sendJson(response, 200, { ok: true, ...result }); + return; + } + + if (method === 'POST' && pathname === '/api/apply') { + const body = await readBody(request); + if (!body) { + fail(response, 'invalid_json'); + return; + } + const result = await service.applyGrant(String(body.serverName ?? '')); + broadcast('state', { reason: 'applied' }); + sendJson(response, 200, { ok: true, ...result }); + return; + } + + if (method === 'POST' && pathname === '/api/refresh') { + const body = await readBody(request); + if (!body) { + fail(response, 'invalid_json'); + return; + } + const result = await service.refreshServer(String(body.serverName ?? '')); + broadcast('state', { reason: 'refreshed' }); + sendJson(response, 200, { ok: true, ...result }); + return; + } + + if (method === 'POST' && pathname === '/api/refresh-all') { + const results = await service.refreshExpiring(); + broadcast('state', { reason: 'refreshed' }); + sendJson(response, 200, { ok: true, results }); + return; + } + + fail(response, 'not_found'); + } + + await new Promise((resolve, reject) => { + const onError = (error) => reject(error); + server.once('error', onError); + server.listen(context.listen.port, context.listen.host, () => { + server.off('error', onError); + resolve(undefined); + }); + }); + context.logger.info(`miniapp.runtime.listening surface=${SURFACE_PATH}`); + + const timer = setInterval(() => { + void service + .refreshExpiring() + .then((results) => { + if (results.length) broadcast('state', { reason: 'auto-refresh' }); + }) + .catch(() => { + // background refresh failures are reported per server in the next state + }); + }, REFRESH_INTERVAL_MS); + timer.unref?.(); + + // Warm the OAuth detection cache in the background; never block readiness. + void (async () => { + try { + const { servers } = await service.listConfiguredServers(); + for (const server of servers) { + if (!server.url) continue; + try { + await service.probeDiscovery(server.name, server.url, server.type); + } catch { + // a single unreachable endpoint must not stop the rest of the cache + } + } + broadcast('state', { reason: 'discovery' }); + } catch { + // discovery cache warms lazily on demand + } + })(); + + let disposed = false; + const dispose = async () => { + if (disposed) return; + disposed = true; + clearInterval(timer); + context.signal.removeEventListener('abort', onAbort); + for (const response of listeners) { + try { + response.end(); + } catch { + // listener already closed + } + } + listeners.clear(); + service.dispose(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve(undefined))); + }); + }; + const onAbort = () => { + void dispose(); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + + return { dispose }; +} + +function renderCallbackPage(result) { + const ok = result.ok; + const title = ok ? '授权完成' : '授权未完成'; + const detail = result.message ?? ''; + const tone = ok ? 'var(--mcode-success)' : 'var(--mcode-danger)'; + return ` + + + + + ${title} + + + +
+

${title}

+

${detail}

+

回到 Mini App 页面确认写入配置即可关闭此标签。

+
+ +`; +} diff --git a/plugins/microbiosis/mcp-oauth-console/package.json b/plugins/microbiosis/mcp-oauth-console/package.json new file mode 100644 index 00000000..aab6b8dc --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} From dac3242bafd8d53b7220a19924d3dde459fec4dc Mon Sep 17 00:00:00 2001 From: Microbiosis <86361982+Microbiosis@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:58:47 +0800 Subject: [PATCH 2/5] Add the device authorization grant to mcp-oauth-console From MiniMax Code 3.1.2 the Host authenticates every request to the assigned loopback port, so the browser redirect of an authorization-code flow answers 401 and the callback handler never runs. The device authorization grant (RFC 8628) has no redirect at all, so it is not affected; the page now offers it as the recommended method and keeps the PKCE method with the callback URL copied back by hand. Also rebase onto upstream main, document the Agent turns and the two sign-in methods, and tag the row action buttons with data-action so they can be targeted unambiguously. --- .../microbiosis/mcp-oauth-console/README.md | 17 +- .../mcp-oauth-console/README.zh-CN.md | 17 +- .../mcp-oauth-console/miniapp/client/app.js | 369 +++++++++++++----- .../miniapp/client/styles.css | 69 ++++ .../miniapp/node/oauth-service.mjs | 185 ++++++++- .../mcp-oauth-console/miniapp/node/server.mjs | 33 +- 6 files changed, 582 insertions(+), 108 deletions(-) diff --git a/plugins/microbiosis/mcp-oauth-console/README.md b/plugins/microbiosis/mcp-oauth-console/README.md index dbf9ca64..fc7fc518 100644 --- a/plugins/microbiosis/mcp-oauth-console/README.md +++ b/plugins/microbiosis/mcp-oauth-console/README.md @@ -21,11 +21,19 @@ The page lists every MCP server in the MiniMax Code configuration and shows **tw | Action | What it does | |---|---| | Detect | Re-probes the endpoint with a handshake that matches the declared transport, and shows the result in that server's own row | -| Authorize | Runs the authorization-code + PKCE flow: dynamic client registration, browser consent, callback, then a confirmation dialog showing exactly which field will change | +| Authorize | Runs an OAuth sign-in for that server, then a confirmation dialog showing exactly which field will change | | Confirm write | Patches `mcpServers..headers.Authorization` in `mcp.json`, backs the file up first, then verifies the token with a real MCP handshake | | Refresh | Exchanges the stored refresh token for a new access token and writes it back | | Refresh expiring | Refreshes every stored token that is within 30 minutes of expiry | +## Signing in + +Two methods are offered, and the page recommends the first one. + +**Device code (recommended).** The server asks the authorization server for a short user code, the page shows it next to a link to the provider's verification page, and the server polls the token endpoint until you approve. There is no redirect, so nothing depends on the browser reaching this Mini App. + +**Authorization code + PKCE.** The browser opens the consent page and the provider redirects back to this Mini App's loopback port. **From MiniMax Code 3.1.2 the Host authenticates every request to that port**, so the callback page answers `401` and your handler never runs. The address bar still carries the authorization code, so copy that whole URL back into the page and the flow completes — the callback page itself is never needed. + ## How detection works The probe uses the transport the server declares in the configuration: @@ -90,6 +98,10 @@ Arguments are passed as an argv array and never through a shell. The URL is the All durable state lives under `context.dataDir`, the private directory the Host creates for this plugin. Nothing is written outside it except the MCP configuration listed above. +### Agent turns + +None. The package declares no `agentAccess`, so it never asks the Agent to run a turn, read a file, or call a tool. Everything happens inside the Node process or in the provider's own page. + ### Credentials Token material stays in the Node process. The page receives metadata only: scope, expiry, and an eight-character SHA-256 fingerprint used to tell two tokens apart. A fingerprint is not reversible and is not usable as a credential. Credentials are never placed in HTML, client JavaScript, log messages, or error responses. @@ -101,7 +113,7 @@ Token material stays in the Node process. The page receives metadata only: scope | MiniMax Code | 3.1.6 (Windows) | | Platform | Windows 11, Node 22 or newer | | Authorization servers | Hugging Face OAuth (`huggingface.co`), Exa OAuth (`auth.exa.ai`) | -| Verified flows | Dynamic client registration, authorization code + PKCE (S256), authorization code exchange, refresh token exchange, MCP `initialize` handshake after a write | +| Verified flows | Dynamic client registration, device authorization (RFC 8628), authorization code + PKCE (S256), authorization code exchange, refresh token exchange, MCP `initialize` handshake after a write | Verified end to end against the Hugging Face authorization server and the Hugging Face Inference Endpoints MCP server. @@ -109,6 +121,7 @@ Verified end to end against the Hugging Face authorization server and the Huggin - The plugin can only manage servers that are already in the MCP configuration, or that you add through the page. It does not discover servers on its own. - A server that needs a credential but does not declare OAuth cannot be signed in to from the page; the header has to be set by hand. +- The PKCE method needs the callback URL copied back by hand, because the Host guards the loopback port. Use the device code method unless the provider offers nothing else. - The detection probe checks the handshake. A server may accept an unauthenticated `initialize` and still require a token for tool calls. - The refresh timer runs while the Mini App is running. A token that expires while the app is stopped is refreshed the next time it starts. diff --git a/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md b/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md index 2038c11e..279536b0 100644 --- a/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md +++ b/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md @@ -21,11 +21,19 @@ | 操作 | 行为 | |---|---| | 检测 | 按服务端声明的传输方式重新探测,结果显示在**该服务器自己那一行** | -| 授权登录 | 走完授权码 + PKCE 流程:动态注册客户端、浏览器同意、接收回调,然后弹出确认框,明确告诉你将改动哪个字段 | +| 授权登录 | 为该服务器完成一次 OAuth 登录,然后弹出确认框,明确告诉你将改动哪个字段 | | 确认写入 | 改写 `mcp.json` 里 `mcpServers..headers.Authorization`,先备份,再用真实的 MCP 握手验活 | | 刷新令牌 | 用已存的 refresh_token 换新 access_token 并写回 | | 刷新即将过期 | 刷新所有距过期不足 30 分钟的令牌 | +## 登录方式 + +页面提供两种方式,并默认推荐第一种。 + +**设备码(推荐)**。服务端向授权服务器申请一串短设备码,页面把它和验证页链接一起显示出来;你在授权页确认后,服务端轮询令牌端点直到拿到授权。**全程没有跳转**,因此不依赖浏览器能不能回到本 App。 + +**授权码 + PKCE**。浏览器打开同意页,授权服务器再跳回本 App 的回环端口。**从 MiniMax Code 3.1.2 起,宿主会对该端口的每个请求做鉴权**,于是回调页会返回 `401`、你的处理器根本不会被执行;但地址栏里仍然带着授权码,把整段 URL 复制回页面即可完成——回调页本身并不需要打开。 + ## 探测是怎么做的 探针使用服务端在配置里声明的传输方式: @@ -90,6 +98,10 @@ 所有持久状态都放在 `context.dataDir`——宿主为这个插件创建的私有目录。除了上面列出的 MCP 配置,不会写到它以外的地方。 +### Agent 回合 + +没有。本包没有声明 `agentAccess`,因此从不让 Agent 跑回合、读文件或调工具;一切都在 Node 进程内或授权服务器自己的页面里完成。 + ### 凭据 令牌本体始终留在 Node 进程里。页面只拿到元信息:scope、过期时间,以及一个八位 SHA-256 指纹,用来分辨两个令牌是否不同。指纹不可逆,也不能当凭据用。凭据不会出现在 HTML、客户端 JavaScript、日志或错误响应中。 @@ -101,7 +113,7 @@ | MiniMax Code | 3.1.6(Windows) | | 平台 | Windows 11,Node 22 及以上 | | 授权服务器 | Hugging Face OAuth(`huggingface.co`)、Exa OAuth(`auth.exa.ai`) | -| 已验证流程 | 动态客户端注册、授权码 + PKCE(S256)、授权码换令牌、refresh_token 换令牌、写入后的 MCP `initialize` 握手 | +| 已验证流程 | 动态客户端注册、设备码流程(RFC 8628)、授权码 + PKCE(S256)、授权码换令牌、refresh_token 换令牌、写入后的 MCP `initialize` 握手 | 已针对 Hugging Face 授权服务器与 Hugging Face Inference Endpoints MCP 服务器端到端验证。 @@ -109,6 +121,7 @@ - 只能管理已经在 MCP 配置里的服务器,或通过页面新增的服务器;不会自己发现服务器。 - 需要凭证但没声明 OAuth 的服务器无法从页面登录,只能手工配置请求头。 +- 授权码方式的回调地址要手工复制回来(宿主对回环端口有鉴权),除非授权服务器不提供设备码流程,否则优先用设备码。 - 探测只验证握手。服务端可能接受未认证的 `initialize`,却在调用工具时才要令牌。 - 续期定时器只在 Mini App 运行时工作。App 停掉期间过期的令牌,会在下一次启动时补刷。 diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/client/app.js b/plugins/microbiosis/mcp-oauth-console/miniapp/client/app.js index 638a2c59..c50854aa 100644 --- a/plugins/microbiosis/mcp-oauth-console/miniapp/client/app.js +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/client/app.js @@ -316,6 +316,10 @@ function createServerRow(server) { }); actions.append(btnApply, btnRefresh, btnAuthorize, btnDetect); + btnApply.dataset.action = 'apply'; + btnRefresh.dataset.action = 'refresh'; + btnAuthorize.dataset.action = 'authorize'; + btnDetect.dataset.action = 'detect'; node.append(main, actions); return { node, badgeSlot, credBadgeSlot, urlNode, metaNode, btnApply, btnRefresh, btnAuthorize, btnDetect }; @@ -498,127 +502,304 @@ function openAuthorizeModal(server) { serverName: server?.name ?? '', url: server?.url ?? '', scope: '', + method: 'device', session: null, + device: null, + deviceStatus: 'idle', + lastStatus: '', error: null, working: false, + pollTimer: null, }; openModal({ title: server?.hasAuthorization ? `重新授权 · ${server.name}` : '授权登录', build: (body) => renderAuthorizeBody(body, draft), footer: [ - { label: '取消', className: 'btn btn--secondary', onClick: closeModal }, - { label: '开始授权', className: 'btn btn--primary', onClick: () => startAuthorization(draft) }, + { + label: '取消', + className: 'btn btn--secondary', + onClick: () => { + stopPolling(draft); + closeModal(); + }, + }, + { + label: '开始授权', + className: 'btn btn--primary', + onClick: () => (draft.method === 'device' ? startDeviceFlow(draft) : startAuthorization(draft)), + }, ], }); } function renderAuthorizeBody(body, draft) { + const switcher = element('div', 'segmented'); + for (const [value, label, hint] of [ + ['device', '设备码(推荐)', '无需回调,最省事'], + ['pkce', '授权码 + PKCE', '需手动回填一次'], + ]) { + const button = element('button', `segmented__item${draft.method === value ? ' is-active' : ''}`, label); + button.type = 'button'; + button.setAttribute('aria-pressed', draft.method === value ? 'true' : 'false'); + button.title = hint; + button.addEventListener('click', () => { + if (draft.method === value) return; + draft.method = value; + draft.error = null; + renderModal(true); + }); + switcher.append(button); + } + body.append(switcher); + + if (draft.method === 'device') { + renderDeviceBody(body, draft); + } else { + renderPkceBody(body, draft); + } + + if (draft.error) { + body.append(element('div', 'notice notice--danger', draft.error)); + } +} + +function renderDeviceBody(body, draft) { + if (!draft.device) { + const steps = element('ol', 'steps'); + for (const text of ['读取授权服务器元数据', '申请设备码', '在授权页输入设备码', '换取令牌', '确认写入 mcp.json']) { + steps.append(element('li', null, text)); + } + body.append(steps); + body.append( + element( + 'div', + 'notice', + '设备码流程不需要浏览器回调,因此不会被宿主对回环端口的鉴权拦住。页面会给你一串设备码,在授权页输入即可。', + ), + ); + body.append(serverFields(draft)); + return; + } + + const code = element('div', 'device-code'); + code.append(element('span', 'device-code__label', '设备码'), element('strong', 'device-code__value', draft.device.userCode)); + body.append(code); + + const link = draft.device.verificationUriComplete || draft.device.verificationUri; + const notice = element('div', 'notice'); + notice.append(element('p', null, '在授权页输入上面的设备码,页面会自动继续:')); + const anchor = element('a', 'btn btn--link', link); + anchor.href = link; + anchor.target = '_blank'; + anchor.rel = 'noreferrer'; + notice.append(anchor); + body.append(notice); + + const actions = element('div', 'server__actions'); + actions.append( + actionButton('打开授权页', 'btn btn--secondary', () => { + window.open(link, '_blank', 'noopener'); + }), + actionButton('复制设备码', 'btn btn--secondary', async () => { + await copyText(draft.device.userCode); + toast('设备码已复制'); + }), + ); + body.append(actions); + + const statusText = + draft.deviceStatus === 'pending' + ? '等待你在授权页确认…' + : draft.deviceStatus === 'granted' + ? '已获得令牌,正在确认写入…' + : draft.deviceStatus === 'expired' + ? '设备码已过期,请重新申请' + : draft.deviceStatus === 'failed' + ? '授权失败,请重新申请' + : '准备中…'; + body.append(element('p', 'field__hint', `状态:${statusText}`)); +} + +function serverFields(draft) { + const wrap = element('div', 'field-stack'); + + const nameInput = element('input'); + nameInput.type = 'text'; + nameInput.value = draft.serverName; + nameInput.placeholder = 'hf-endpoints'; + nameInput.addEventListener('input', () => { + draft.serverName = nameInput.value; + }); + wrap.append(field('服务器名称(写入 mcp.json 的键名)', nameInput, '名称已存在时覆盖该条目的 Authorization 头,否则新建条目。')); + + const urlInput = element('input'); + urlInput.type = 'url'; + urlInput.value = draft.url; + urlInput.placeholder = 'https://endpoints.huggingface.co/mcp'; + urlInput.addEventListener('input', () => { + draft.url = urlInput.value; + }); + wrap.append(field('MCP 地址', urlInput, '受 OAuth 保护的 MCP 服务端点。')); + + const scopeInput = element('input'); + scopeInput.type = 'text'; + scopeInput.value = draft.scope; + scopeInput.placeholder = 'read-endpoints write-endpoints'; + scopeInput.addEventListener('input', () => { + draft.scope = scopeInput.value; + }); + wrap.append(field('scope(可选,留空则用授权服务器默认值)', scopeInput)); + return wrap; +} + +function renderPkceBody(body, draft) { const steps = element('ol', 'steps'); - const items = [ - { key: 'discover', text: '读取授权服务器元数据' }, - { key: 'register', text: '动态注册客户端' }, - { key: 'browser', text: '在浏览器中完成授权' }, - { key: 'callback', text: '接收回调并换取令牌' }, - { key: 'apply', text: '确认写入 mcp.json' }, - ]; + const items = ['读取授权服务器元数据', '动态注册客户端', '在浏览器中完成授权', '回填回调地址', '确认写入 mcp.json']; const reached = draft.session ? 3 : 0; - for (const [index, item] of items.entries()) { - const li = element('li', null, item.text); + items.forEach((text, index) => { + const li = element('li', null, text); if (index < reached) li.dataset.state = 'done'; else if (index === reached) li.dataset.state = 'active'; steps.append(li); - } + }); body.append(steps); if (!draft.session) { - const nameInput = element('input'); - nameInput.type = 'text'; - nameInput.value = draft.serverName; - nameInput.placeholder = 'hf-endpoints'; - nameInput.addEventListener('input', () => { - draft.serverName = nameInput.value; - }); body.append( - field('服务器名称(写入 mcp.json 的键名)', nameInput, '名称已存在时覆盖该条目的 Authorization 头,否则新建条目。'), - ); - - const urlInput = element('input'); - urlInput.type = 'url'; - urlInput.value = draft.url; - urlInput.placeholder = 'https://endpoints.huggingface.co/mcp'; - urlInput.addEventListener('input', () => { - draft.url = urlInput.value; - }); - body.append(field('MCP 地址', urlInput, '受 OAuth 保护的 MCP 服务端点。')); - - const scopeInput = element('input'); - scopeInput.type = 'text'; - scopeInput.value = draft.scope; - scopeInput.placeholder = 'read-endpoints write-endpoints'; - scopeInput.addEventListener('input', () => { - draft.scope = scopeInput.value; - }); - body.append(field('scope(可选,留空则用授权服务器默认值)', scopeInput)); - } else { - const notice = element('div', 'notice'); - notice.append( - element('p', null, '浏览器已打开授权页。如果未能自动跳转,请复制下面的链接在新标签打开:'), - ); - const link = element('a', 'btn btn--link', draft.session.authorizationUrl); - link.href = draft.session.authorizationUrl; - link.target = '_blank'; - link.rel = 'noreferrer'; - notice.append(link); - body.append(notice); - - const copyRow = element('div', 'server__actions'); - copyRow.append( - actionButton('复制授权链接', 'btn btn--secondary', async () => { - await copyText(draft.session.authorizationUrl); - toast(`${draft.serverName} 授权链接已复制`); - }), - ); - body.append(copyRow); - - const callbackInput = element('textarea'); - callbackInput.placeholder = 'https://127.0.0.1:PORT/oauth/callback?code=...&state=...'; - body.append( - field( - '回退:粘贴浏览器跳转后的完整地址', - callbackInput, - '如果回调页被浏览器拦截,把地址栏里的完整 URL 粘贴到这里再点「用回填完成」。', + element( + 'div', + 'notice', + '浏览器会跳回本机端口,但宿主对回环端口有鉴权,那个页面会显示 401。地址栏里仍然带着授权码,把它复制回来即可。', ), ); + body.append(serverFields(draft)); + return; + } - const manualRow = element('div', 'server__actions'); - manualRow.append( - actionButton('用回填完成', 'btn btn--primary', async () => { - const value = callbackInput.value.trim(); - if (!value) { - toast('请先粘贴回调地址', 'error'); - return; - } - draft.working = true; - try { - await api('/api/authorize/complete', { - method: 'POST', - body: { sessionId: draft.session.sessionId, redirectUrl: value }, - }); - toast(`${draft.serverName} 已获取令牌,请在列表中确认写入`); - closeModal(); - await loadState(); - } catch (error) { - toast(`${draft.serverName} 完成授权失败:${error.message}`, 'error'); - } finally { - draft.working = false; - } - }), - ); - body.append(manualRow); + const notice = element('div', 'notice'); + notice.append(element('p', null, '浏览器已打开授权页。如果未能自动跳转,请复制下面的链接在新标签打开:')); + const link = element('a', 'btn btn--link', draft.session.authorizationUrl); + link.href = draft.session.authorizationUrl; + link.target = '_blank'; + link.rel = 'noreferrer'; + notice.append(link); + body.append(notice); + + const copyRow = element('div', 'server__actions'); + copyRow.append( + actionButton('复制授权链接', 'btn btn--secondary', async () => { + await copyText(draft.session.authorizationUrl); + toast(`${draft.serverName} 授权链接已复制`); + }), + ); + body.append(copyRow); + + const callbackInput = element('textarea'); + callbackInput.placeholder = 'http://127.0.0.1:PORT/oauth/callback?code=...&state=...'; + body.append( + field( + '回填:粘贴浏览器地址栏里的完整 URL', + callbackInput, + '授权完成后浏览器地址栏会是一段带 code 的地址,整段复制过来即可,无需回填页真的打开。', + ), + ); + + const manualRow = element('div', 'server__actions'); + manualRow.append( + actionButton('用回填完成', 'btn btn--primary', async () => { + const value = callbackInput.value.trim(); + if (!value) { + toast('请先粘贴回调地址', 'error'); + return; + } + draft.working = true; + try { + await api('/api/authorize/complete', { + method: 'POST', + body: { sessionId: draft.session.sessionId, redirectUrl: value }, + }); + toast(`${draft.serverName} 已获取令牌,请在列表中确认写入`); + closeModal(); + await loadState(); + } catch (error) { + toast(`${draft.serverName} 完成授权失败:${error.message}`, 'error'); + } finally { + draft.working = false; + } + }), + ); + body.append(manualRow); +} + +function startDeviceFlow(draft) { + if (!draft.serverName.trim()) { + toast('请填写服务器名称', 'error'); + return; } + if (!draft.url.trim()) { + toast('请填写 MCP 地址', 'error'); + return; + } + draft.working = true; + draft.error = null; + renderModal(true); + api('/api/device/start', { + method: 'POST', + body: { + serverName: draft.serverName.trim(), + url: draft.url.trim(), + scope: draft.scope.trim() || undefined, + }, + }) + .then((result) => { + draft.device = result; + draft.deviceStatus = 'pending'; + draft.lastStatus = 'pending'; + renderModal(true); + pollDeviceStatus(draft); + }) + .catch((error) => { + draft.error = `发起设备码失败:${error.message}`; + renderModal(true); + }) + .finally(() => { + draft.working = false; + }); +} - if (draft.error) { - body.append(element('div', 'notice notice--danger', draft.error)); +function pollDeviceStatus(draft) { + stopPolling(draft); + draft.pollTimer = setInterval(async () => { + let result = null; + try { + result = await api(`/api/device/status?serverName=${encodeURIComponent(draft.serverName.trim())}`); + } catch { + return; // transient: keep polling + } + if (result.status !== draft.lastStatus) { + draft.lastStatus = result.status; + draft.deviceStatus = result.status; + renderModal(true); + } + if (result.status === 'granted') { + stopPolling(draft); + toast(`${draft.serverName} 已获取令牌,请在列表中确认写入`); + closeModal(); + void loadState(); + return; + } + if (result.status === 'failed' || result.status === 'expired') { + stopPolling(draft); + draft.error = `设备码授权${result.status === 'expired' ? '已过期' : '失败'}${result.error ? `(${result.error})` : ''}`; + renderModal(true); + } + }, 3000); +} + +function stopPolling(draft) { + if (draft.pollTimer) { + clearInterval(draft.pollTimer); + draft.pollTimer = null; } } diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/client/styles.css b/plugins/microbiosis/mcp-oauth-console/miniapp/client/styles.css index 29514a0c..d55b3d35 100644 --- a/plugins/microbiosis/mcp-oauth-console/miniapp/client/styles.css +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/client/styles.css @@ -546,6 +546,75 @@ main { color: var(--mcode-text-muted); } +/* Segmented switch between the two authorization methods. */ +.segmented { + display: inline-flex; + gap: 2px; + padding: 2px; + border-radius: 8px; + background: var(--mcode-surface-muted); + border: 1px solid var(--mcode-border); + align-self: flex-start; +} + +.segmented__item { + min-height: 28px; + padding: 0 12px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--mcode-text-muted); + font: inherit; + font-size: 13px; + cursor: pointer; + transition: background-color 150ms ease-out, color 150ms ease-out; +} + +.segmented__item:hover { + color: var(--mcode-text); +} + +.segmented__item.is-active { + background: var(--mcode-surface); + color: var(--mcode-text); + font-weight: 600; +} + +.segmented__item:focus-visible { + outline: 2px solid var(--mcode-accent); + outline-offset: 1px; +} + +.field-stack { + display: flex; + flex-direction: column; + gap: 12px; +} + +.device-code { + display: flex; + flex-direction: column; + gap: 2px; + padding: 12px 16px; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-surface-muted); +} + +.device-code__label { + font-size: 12px; + line-height: 16px; + color: var(--mcode-text-muted); +} + +.device-code__value { + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 24px; + line-height: 30px; + letter-spacing: 0.12em; + color: var(--mcode-text); +} + .steps li[data-state='done'] { color: var(--mcode-success); } diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs b/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs index c617388b..5f2c7c52 100644 --- a/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs @@ -412,16 +412,19 @@ function pickAuthMethod(metadata) { return 'client_secret_post'; } -async function registerClient(metadata, { redirectUri, scope }) { +async function registerClient(metadata, { redirectUri, scope, grantTypes }) { const endpoint = metadata?.registration_endpoint; if (!endpoint) throw serviceError('dynamic_registration_unsupported'); const supported = Array.isArray(metadata.grant_types_supported) ? metadata.grant_types_supported : []; - const grantTypes = ['authorization_code']; - if (supported.includes('refresh_token') || supported.length === 0) grantTypes.push('refresh_token'); + const requested = grantTypes ?? (() => { + const list = ['authorization_code']; + if (supported.includes('refresh_token') || supported.length === 0) list.push('refresh_token'); + return list; + })(); const body = { client_name: CLIENT_NAME, redirect_uris: [redirectUri], - grant_types: grantTypes, + grant_types: requested, response_types: ['code'], token_endpoint_auth_method: pickAuthMethod(metadata), }; @@ -498,7 +501,7 @@ export async function probeMcpEndpoint(url, token, transport) { /* Service */ /* ------------------------------------------------------------------ */ -export function createOAuthService({ dataDir, baseUrl, logger }) { +export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { const mcpConfigPath = resolveMcpConfigPath(dataDir); // Durable state belongs in the Host-owned dataDir. A credential directory // left next to mcp.json by an earlier version is read once and imported; @@ -513,6 +516,8 @@ export function createOAuthService({ dataDir, baseUrl, logger }) { const discoveryCache = new Map(); /** serverName -> pending grant awaiting user confirmation */ const awaitingConfirmation = new Map(); + /** serverName -> device authorization session (RFC 8628) */ + const deviceSessions = new Map(); const children = new Set(); @@ -1069,7 +1074,175 @@ export function createOAuthService({ dataDir, baseUrl, logger }) { return results; } + /** + * RFC 8628 device authorization. There is no redirect, so the Host's port + * guard cannot block anything: the user types a short code in the provider's + * page and the server polls the token endpoint until the grant lands. + */ + async function startDeviceAuthorization({ serverName, url, scope }) { + const { servers } = await listConfiguredServers(); + const existing = servers.find((item) => item.name === serverName); + const targetUrl = existing?.url || url; + if (!targetUrl) throw serviceError('target_url_required'); + assertTargetUrl(targetUrl); + const transport = existing?.type === 'sse' ? 'sse' : 'streamable-http'; + const discovery = await probeDiscovery(serverName, targetUrl, transport); + const metadata = discovery.metadata; + if (!metadata?.device_authorization_endpoint) throw serviceError('device_flow_unsupported'); + // The client must be allowed to use the device grant, or the authorization + // server rejects the device request. + const client = await registerClient(metadata, { + redirectUri: `${baseUrl}${CALLBACK_PATH}`, + scope, + grantTypes: [ + 'urn:ietf:params:oauth:grant-type:device_code', + 'authorization_code', + 'refresh_token', + ], + }); + const form = new URLSearchParams(); + form.set('client_id', client.client_id); + if (scope) form.set('scope', scope); + // The device endpoint authenticates the client like the token endpoint: + // a secret in the body, or a Basic header. Without it the server answers + // 400 invalid_client. + const deviceHeaders = { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }; + const authMethod = client.token_endpoint_auth_method ?? pickAuthMethod(metadata); + if (client.client_secret && authMethod === 'client_secret_post') { + form.set('client_secret', client.client_secret); + } + if (client.client_secret && authMethod === 'client_secret_basic') { + deviceHeaders.authorization = `Basic ${Buffer.from(`${client.client_id}:${client.client_secret}`).toString('base64')}`; + } + const response = await httpJson(metadata.device_authorization_endpoint, { + method: 'POST', + headers: deviceHeaders, + body: form.toString(), + }); + if (!response.ok || !response.json?.device_code) { + log('warn', `oauth.device_request_failed server=${serverName} status=${response.status} body=${String(response.text).slice(0, 200)}`); + throw serviceError(`device_authorization_failed_${response.status}`); + } + const info = response.json; + const session = { + serverName, + url: targetUrl, + metadata, + client: { + client_id: client.client_id, + client_secret: client.client_secret ?? null, + auth_method: client.token_endpoint_auth_method ?? pickAuthMethod(metadata), + }, + deviceCode: info.device_code, + scope: scope ?? null, + interval: Math.max(2, Number(info.interval) || 5), + expiresAt: Date.now() + (Number(info.expires_in) || 600) * 1000, + status: 'pending', + error: null, + timer: null, + }; + deviceSessions.get(serverName)?.timer && clearTimeout(deviceSessions.get(serverName).timer); + deviceSessions.set(serverName, session); + startDevicePolling(session); + log('info', `oauth.device_started server=${serverName} interval=${session.interval}`); + return { + userCode: info.user_code, + verificationUri: info.verification_uri, + verificationUriComplete: info.verification_uri_complete ?? null, + expiresIn: Number(info.expires_in) || 600, + interval: session.interval, + }; + } + + function startDevicePolling(session) { + const schedule = () => { + session.timer = setTimeout(async () => { + if (deviceSessions.get(session.serverName) !== session) return; + await pollDeviceOnce(session); + if (deviceSessions.get(session.serverName) === session) schedule(); + }, session.interval * 1000); + session.timer.unref?.(); + }; + schedule(); + } + + async function pollDeviceOnce(session) { + if (Date.now() > session.expiresAt) { + deviceSessions.delete(session.serverName); + session.status = 'expired'; + session.error = 'expired_token'; + log('warn', `oauth.device_expired server=${session.serverName}`); + onChange?.(); + return; + } + const form = new URLSearchParams(); + form.set('grant_type', 'urn:ietf:params:oauth:grant-type:device_code'); + form.set('device_code', session.deviceCode); + form.set('client_id', session.client.client_id); + const headers = { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }; + if (session.client.client_secret && session.client.auth_method === 'client_secret_post') { + form.set('client_secret', session.client.client_secret); + } + if (session.client.client_secret && session.client.auth_method === 'client_secret_basic') { + headers.authorization = `Basic ${Buffer.from(`${session.client.client_id}:${session.client.client_secret}`).toString('base64')}`; + } + let body = null; + try { + const response = await httpJson(session.metadata.token_endpoint, { method: 'POST', headers, body: form.toString() }); + body = response.json ?? {}; + if (response.ok && body.access_token) { + deviceSessions.delete(session.serverName); + session.status = 'granted'; + awaitingConfirmation.set(session.serverName, { + serverName: session.serverName, + url: session.url, + token: body, + client: session.client, + scope: session.scope, + createdAt: Date.now(), + }); + log('info', `oauth.device_authorized server=${session.serverName}`); + onChange?.(); + return; + } + } catch (error) { + // A transient network failure must not abort the grant: keep polling. + session.status = 'pending'; + return; + } + const error = body.error ?? 'authorization_pending'; + if (error === 'authorization_pending') { + session.status = 'pending'; + return; + } + if (error === 'slow_down') { + session.interval += 5; + session.status = 'pending'; + return; + } + deviceSessions.delete(session.serverName); + session.status = 'failed'; + session.error = error; + log('warn', `oauth.device_failed server=${session.serverName} reason=${error}`); + onChange?.(); + } + + function deviceStatus(serverName) { + const session = deviceSessions.get(serverName); + if (!session) return { status: 'idle' }; + return { + status: session.status, + error: session.error ?? null, + expiresAt: session.expiresAt, + interval: session.interval, + }; + } + function dispose() { + for (const session of deviceSessions.values()) { + if (session.timer) clearTimeout(session.timer); + } + deviceSessions.clear(); for (const child of children) { try { child.kill(); @@ -1094,6 +1267,8 @@ export function createOAuthService({ dataDir, baseUrl, logger }) { handleCallback, applyGrant, writeAuthorization: patchMcpAuthorization, + startDeviceAuthorization, + deviceStatus, refreshServer, refreshExpiring, dispose, diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs b/plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs index 137e3421..abfb32de 100644 --- a/plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs @@ -37,11 +37,6 @@ const CONTENT_TYPES = { export async function start(context) { const clientDir = join(context.pluginRoot, 'miniapp', 'client'); const baseUrl = `http://127.0.0.1:${context.listen.port}`; - const service = createOAuthService({ - dataDir: context.dataDir, - baseUrl, - logger: context.logger, - }); /** @type {Set} */ const listeners = new Set(); @@ -57,6 +52,13 @@ export async function start(context) { } }; + const service = createOAuthService({ + dataDir: context.dataDir, + baseUrl, + logger: context.logger, + onChange: () => broadcast('state', { reason: 'service' }), + }); + const sendJson = (response, status, body) => { const text = JSON.stringify(body); response.writeHead(status, { @@ -215,6 +217,27 @@ export async function start(context) { return; } + if (method === 'POST' && pathname === '/api/device/start') { + const body = await readBody(request); + if (!body) { + fail(response, 'invalid_json'); + return; + } + const result = await service.startDeviceAuthorization({ + serverName: String(body.serverName ?? ''), + url: body.url ? String(body.url) : undefined, + scope: body.scope ? String(body.scope) : undefined, + }); + sendJson(response, 200, { ok: true, ...result }); + return; + } + + if (method === 'GET' && pathname === '/api/device/status') { + const serverName = url.searchParams.get('serverName') ?? ''; + sendJson(response, 200, { ok: true, ...service.deviceStatus(serverName) }); + return; + } + if (method === 'POST' && pathname === '/api/authorize/complete') { const body = await readBody(request); if (!body) { From 6a3c42ef86b6cc2563e45d6e18134c769e487160 Mon Sep 17 00:00:00 2001 From: Microbiosis <86361982+Microbiosis@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:54:59 +0800 Subject: [PATCH 3/5] Address the review on #77: ship the missing error catalog, tighten the guards Must fix - package miniapp/node/errors.mjs, which the entry imports but the package did not ship, so the Mini App could not start at all - keep every error code the service can raise in one catalog; an unregistered code now fails fast instead of reaching the user - restrict a server name to a safe set, so it can no longer walk out of the credential directory - locate mcp.json only in the Host data-dir chain and the documented default location; the working directory and the Node executable's directory are no longer searched, and the resolved path is realpath'ed and shown in the page - bind a write to the exact path the user confirmed - run the whole read-modify-write under one lock and re-read inside it - refuse a write when the backup failed, and keep the confirmed snapshot apart from the one a background refresh writes - make the automatic refresh an explicit switch, off by default - validate every outbound URL, including the IPv4-mapped, IPv4-compatible and NAT64 IPv6 forms; URLs a remote server handed back are always strict - escape the callback page, and require the PKCE state to match exactly - limit the JSON lookup to mcpServers, and insert a new entry textually Optional - bind a confirmed write to a one-time token - report a browser that could not be opened, instead of claiming it opened - drop the unused grant TTL - add unit tests for the URL guard, the credential path, the surgical patch and the error catalog --- .../.minimax-plugin/plugin.json | 2 +- .../microbiosis/mcp-oauth-console/README.md | 75 ++- .../mcp-oauth-console/README.zh-CN.md | 77 ++- .../mcp-oauth-console/miniapp/client/app.js | 49 +- .../miniapp/client/index.html | 19 + .../miniapp/client/styles.css | 28 ++ .../mcp-oauth-console/miniapp/node/errors.mjs | 97 ++++ .../miniapp/node/oauth-service.mjs | 472 +++++++++++++----- .../mcp-oauth-console/miniapp/node/server.mjs | 40 +- .../tests/authorization-patch.test.mjs | 101 ++++ .../tests/credential-path.test.mjs | 30 ++ .../tests/entry-loads.test.mjs | 29 ++ .../tests/url-guard.test.mjs | 60 +++ 13 files changed, 936 insertions(+), 143 deletions(-) create mode 100644 plugins/microbiosis/mcp-oauth-console/miniapp/node/errors.mjs create mode 100644 plugins/microbiosis/mcp-oauth-console/tests/authorization-patch.test.mjs create mode 100644 plugins/microbiosis/mcp-oauth-console/tests/credential-path.test.mjs create mode 100644 plugins/microbiosis/mcp-oauth-console/tests/entry-loads.test.mjs create mode 100644 plugins/microbiosis/mcp-oauth-console/tests/url-guard.test.mjs diff --git a/plugins/microbiosis/mcp-oauth-console/.minimax-plugin/plugin.json b/plugins/microbiosis/mcp-oauth-console/.minimax-plugin/plugin.json index f1fd30f1..7c72ffd5 100644 --- a/plugins/microbiosis/mcp-oauth-console/.minimax-plugin/plugin.json +++ b/plugins/microbiosis/mcp-oauth-console/.minimax-plugin/plugin.json @@ -3,7 +3,7 @@ "name": "mcp-oauth-console", "displayName": "MCP OAuth 令牌台", "version": "1.0.0", - "description": "为受 OAuth 保护的 MCP 服务器完成授权码 + PKCE 登录,自动续期,并把令牌写回本地 mcp.json。", + "description": "为受 OAuth 保护的 MCP 服务器完成设备码或授权码 + PKCE 登录,可选择自动续期,并把令牌写回本地 mcp.json。", "author": "Microbiosis", "icon": "icon.png", "category": "Code", diff --git a/plugins/microbiosis/mcp-oauth-console/README.md b/plugins/microbiosis/mcp-oauth-console/README.md index fc7fc518..b231dd2f 100644 --- a/plugins/microbiosis/mcp-oauth-console/README.md +++ b/plugins/microbiosis/mcp-oauth-console/README.md @@ -2,7 +2,7 @@ [简体中文](README.zh-CN.md) | English -Sign in to MCP servers that require OAuth: runs the authorization-code + PKCE flow in your browser, keeps the token out of the page, writes the `Authorization` header into the MiniMax Code MCP configuration, and refreshes it before it expires. +Sign in to MCP servers that require OAuth: runs the device-code flow (or authorization-code + PKCE), keeps the token out of the page, writes the `Authorization` header into the MiniMax Code MCP configuration, and can refresh it before it expires. Author: [Microbiosis](https://github.com/Microbiosis) · Version: `1.0.0` @@ -21,10 +21,10 @@ The page lists every MCP server in the MiniMax Code configuration and shows **tw | Action | What it does | |---|---| | Detect | Re-probes the endpoint with a handshake that matches the declared transport, and shows the result in that server's own row | -| Authorize | Runs an OAuth sign-in for that server, then a confirmation dialog showing exactly which field will change | +| Authorize | Runs an OAuth sign-in for that server, then a confirmation dialog showing exactly which file and field will change | | Confirm write | Patches `mcpServers..headers.Authorization` in `mcp.json`, backs the file up first, then verifies the token with a real MCP handshake | | Refresh | Exchanges the stored refresh token for a new access token and writes it back | -| Refresh expiring | Refreshes every stored token that is within 30 minutes of expiry | +| Refresh expiring | Refreshes every stored token that is within 30 minutes of expiry. This is a manual action and always runs, regardless of the automatic-refresh setting | ## Signing in @@ -34,6 +34,19 @@ Two methods are offered, and the page recommends the first one. **Authorization code + PKCE.** The browser opens the consent page and the provider redirects back to this Mini App's loopback port. **From MiniMax Code 3.1.2 the Host authenticates every request to that port**, so the callback page answers `401` and your handler never runs. The address bar still carries the authorization code, so copy that whole URL back into the page and the flow completes — the callback page itself is never needed. +Either way the `state` value must match exactly, and a successful sign-in does not write anything yet: the page shows a confirmation first, and only the confirmation writes the file. + +## Settings + +The settings panel holds two switches and the path of the file that a write would touch. + +| Setting | Default | Effect | +|---|---|---| +| Refresh automatically before expiry | off | While the Mini App is running, tokens inside the 30-minute window are refreshed in the background. Off by default, so the plugin never talks to an authorization server on its own. | +| Allow private addresses | off | Permits loopback, RFC 1918, carrier-grade NAT, link-local and unique-local addresses for **the MCP endpoint you configured yourself**. This switch does not apply to URLs that a remote server hands back — those are always checked strictly. | + +The configuration path shown in the panel is the exact file a write would touch. It is resolved once, symlinks are resolved, and the write is refused if the path changed in the meantime. + ## How detection works The probe uses the transport the server declares in the configuration: @@ -61,7 +74,12 @@ When a server does not declare OAuth the browser flow is not offered: there is n |---|---| | The MiniMax Code MCP server configuration (`mcp.json`) | The list of servers to inspect, and the header to write. | -`mcp.json` is a Host file with no supported API for plugins. This plugin locates it by walking up from `context.dataDir`, then from the process working directory, then from the directory of the Node executable, and finally falls back to `~/.minimax/mcp.json`. **This relies on an unspecified layout**: on a Host that keeps the configuration somewhere else, the plugin finds no servers and shows an empty list. +`mcp.json` is a Host file with no supported API for plugins. The plugin looks for it in exactly two places: + +1. the Host's own data-dir chain — it walks up from `context.dataDir` (the plugin's data directory lives inside the MiniMax data directory) and takes the first `mcp.json` it finds; +2. the documented default location, `~/.minimax/mcp.json`. + +The process working directory and the Node executable's directory are deliberately **not** searched, so an unrelated `mcp.json` next to them can never win. The layout is still unspecified: on a Host that keeps the configuration somewhere else, the plugin finds no servers and shows an empty list. A credential directory left next to `mcp.json` by an earlier version of this plugin is read once and imported into `context.dataDir`. It is never written to again. @@ -70,29 +88,40 @@ A credential directory left next to `mcp.json` by an earlier version of this plu | File | What | |---|---| | `mcp.json` | Only the `Authorization` header of one server entry. The patch is surgical: the rest of the file stays byte-identical. | -| `/mcp-credentials/.json` | The credential: client id, client secret, refresh token, access token, scope, expiry. | +| `/mcp-credentials/.json` | The credential: client id, client secret, refresh token, access token, scope, expiry. The file name is the server name, restricted to `A-Z a-z 0-9 . _ -` and at most 64 characters, so a name such as `../../other` cannot leave the directory. | +| `/settings.json` | The two settings above. | | `/mcp.json.bak` | A copy of `mcp.json`, taken before every write. | +| `/mcp.json.applied.bak` | A copy of `mcp.json`, taken before a **confirmed** write. A background refresh writes to `mcp.json.bak` instead, so it can never replace the snapshot the user was shown. | + +Writes to `mcp.json` are atomic: a temporary file in the same directory, then a rename. The original file mode is carried over, because a temp file starts with the default mode. Writes are serialized so two of them cannot interleave. If a write fails the temporary file is removed and the previous content stays intact. **A failed backup aborts the write** — the backup is the only way back, so it is written before the file is touched, and if that fails nothing is changed. -Writes to `mcp.json` are atomic: a temporary file in the same directory, then a rename. The original file mode is carried over, because a temp file starts with the default mode. Writes are serialized so two of them cannot interleave. If a write fails the temporary file is removed and the previous content stays intact. +The read, the patch and the write all happen under one lock, and the file is re-read inside the lock, so a concurrent edit cannot lose an entry. ### Network hosts The plugin talks to exactly two kinds of hosts: 1. The MCP endpoints listed in the MCP configuration — for the detection probe and for the post-write verification handshake. -2. The OAuth endpoints of those servers, taken from their metadata: the authorization endpoint, the token endpoint, the protected-resource document, and the `.well-known` authorization-server metadata. +2. The OAuth endpoints of those servers, taken from their metadata: the authorization endpoint, the token endpoint, the device-authorization endpoint, the client-registration endpoint, the protected-resource document, and the `.well-known` authorization-server metadata. Nothing else is contacted. There is no telemetry, no analytics, and no third-party service. -A target URL is validated before the process requests it: `https` only (plain `http` is accepted for loopback addresses), no credentials inside the URL, and instance-metadata or link-local addresses are refused. +Every URL is validated before the process requests it: + +- `https` only — plain `http` is accepted for loopback addresses; +- no credentials inside the URL; +- loopback, RFC 1918 (`10/8`, `172.16/12`, `192.168/16`), carrier-grade NAT (`100.64/10`), link-local (`169.254/16`), IPv6 unique-local (`fc00::/7`), IPv6 link-local (`fe80::/10`), IPv6 multicast, the unspecified address, and the instance-metadata hosts are refused; +- IPv4-mapped, IPv4-compatible and NAT64 IPv6 forms are resolved back to their IPv4 address first, so `::ffff:127.0.0.1` and `::ffff:7f00:1` are refused as well. + +Two tiers exist. A URL that a **remote server** handed back — a metadata document, a token endpoint, a device endpoint — is always checked strictly and can never reach a private address. A URL the **user** configured as their own MCP endpoint may point at a private address, but only when the "Allow private addresses" switch is on. ### Spawned processes | Command | When | |---|---| -| `explorer.exe ` (Windows) or `open ` (macOS) | Once per authorization, to open the consent page in the default browser. | +| `explorer.exe ` (Windows), `open ` (macOS), `xdg-open ` (Linux) | Once per authorization, to open the consent page in the default browser. | -Arguments are passed as an argv array and never through a shell. The URL is the authorization request built from the server's own metadata. +Arguments are passed as an argv array and never through a shell. The URL is the authorization request built from the server's own metadata. If the helper cannot be started the page says so and shows the link to copy instead. ### Where state is stored @@ -102,10 +131,24 @@ All durable state lives under `context.dataDir`, the private directory the Host None. The package declares no `agentAccess`, so it never asks the Agent to run a turn, read a file, or call a tool. Everything happens inside the Node process or in the provider's own page. +### Conversations read + +None. The package declares no `session.read`, so it never sees a conversation, a turn, or a reply. + +### Tools called + +None. The package declares no `toolAccess`, so it never calls an MCP or Connector tool. + +### Microphone + +None. The package declares no `deviceAccess`, so it never records the user. + ### Credentials Token material stays in the Node process. The page receives metadata only: scope, expiry, and an eight-character SHA-256 fingerprint used to tell two tokens apart. A fingerprint is not reversible and is not usable as a credential. Credentials are never placed in HTML, client JavaScript, log messages, or error responses. +Internal error codes are never shown to the user. Every code the service can raise is registered in one catalog that carries the user-facing message, and an unregistered code fails fast instead of leaking through. + ## Tested environment | | | @@ -123,7 +166,17 @@ Verified end to end against the Hugging Face authorization server and the Huggin - A server that needs a credential but does not declare OAuth cannot be signed in to from the page; the header has to be set by hand. - The PKCE method needs the callback URL copied back by hand, because the Host guards the loopback port. Use the device code method unless the provider offers nothing else. - The detection probe checks the handshake. A server may accept an unauthenticated `initialize` and still require a token for tool calls. -- The refresh timer runs while the Mini App is running. A token that expires while the app is stopped is refreshed the next time it starts. +- Automatic refresh only runs while the Mini App is running, and it is off until you switch it on. A token that expires while the app is stopped is refreshed the next time it starts. + +## Development + +The package ships its own unit tests. Run them from this directory: + +```bash +node --test "tests/**/*.test.mjs" +``` + +They cover the URL guard, the credential-path confinement, the surgical `mcp.json` patch, and a static check that every error code the service can raise is registered in the catalog. ## License diff --git a/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md b/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md index 279536b0..cb194ffe 100644 --- a/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md +++ b/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md @@ -2,7 +2,7 @@ 简体中文 | [English](README.md) -给需要 OAuth 的 MCP 服务器完成登录:在浏览器里跑授权码 + PKCE 流程,令牌全程不进页面,把 `Authorization` 头写进 MiniMax Code 的 MCP 配置,并在到期前自动续期。 +给需要 OAuth 的 MCP 服务器完成登录:跑设备码流程(或授权码 + PKCE),令牌全程不进页面,把 `Authorization` 头写进 MiniMax Code 的 MCP 配置,并可以在到期前续期。 作者:[Microbiosis](https://github.com/Microbiosis) · 版本:`1.0.0` @@ -21,10 +21,10 @@ | 操作 | 行为 | |---|---| | 检测 | 按服务端声明的传输方式重新探测,结果显示在**该服务器自己那一行** | -| 授权登录 | 为该服务器完成一次 OAuth 登录,然后弹出确认框,明确告诉你将改动哪个字段 | +| 授权登录 | 为该服务器完成一次 OAuth 登录,然后弹出确认框,明确告诉你将改动哪个文件的哪个字段 | | 确认写入 | 改写 `mcp.json` 里 `mcpServers..headers.Authorization`,先备份,再用真实的 MCP 握手验活 | | 刷新令牌 | 用已存的 refresh_token 换新 access_token 并写回 | -| 刷新即将过期 | 刷新所有距过期不足 30 分钟的令牌 | +| 刷新即将过期 | 刷新所有距过期不足 30 分钟的令牌。这是手动操作,**不受自动续期开关影响**,一定会执行 | ## 登录方式 @@ -34,6 +34,19 @@ **授权码 + PKCE**。浏览器打开同意页,授权服务器再跳回本 App 的回环端口。**从 MiniMax Code 3.1.2 起,宿主会对该端口的每个请求做鉴权**,于是回调页会返回 `401`、你的处理器根本不会被执行;但地址栏里仍然带着授权码,把整段 URL 复制回页面即可完成——回调页本身并不需要打开。 +两种方式都要求 `state` 精确匹配。而且**登录成功并不会立刻写文件**:页面先给出确认,只有确认之后才落盘。 + +## 设置 + +设置面板里有两个开关,以及写入时会碰到的那个文件路径。 + +| 设置 | 默认 | 作用 | +|---|---|---| +| 到期前自动续期 | 关 | Mini App 运行期间,落在 30 分钟窗口内的令牌会被后台刷新。默认关闭,插件不会自作主张地去联系授权服务器。 | +| 允许内网地址 | 关 | 允许**你自己配置的 MCP 端点**使用回环地址、RFC 1918、运营商级 NAT、链路本地与唯一本地地址。这个开关**不适用于**远程服务器返回的 URL——那些一律按最严格的标准检查。 | + +面板里显示的配置路径就是写入真正会碰到的那个文件。它只解析一次,符号链接会被解析成真实路径;如果写入前路径变了,写入会被拒绝。 + ## 探测是怎么做的 探针使用服务端在配置里声明的传输方式: @@ -61,7 +74,12 @@ |---|---| | MiniMax Code 的 MCP 服务器配置(`mcp.json`) | 要检查的服务器列表,以及要写入的请求头 | -`mcp.json` 是宿主文件,插件没有受支持的 API 访问它。本插件的定位方式依次是:从 `context.dataDir` 向上找、从进程工作目录向上找、从 Node 可执行文件所在目录向上找,最后回落到 `~/.minimax/mcp.json`。**这依赖未公开的布局**:如果宿主把配置放在别处,插件会找不到服务器并显示空列表。 +`mcp.json` 是宿主文件,插件没有受支持的 API 访问它。本插件只找两个地方: + +1. 宿主自己的数据目录链——从 `context.dataDir` 向上找(插件的数据目录就在 MiniMax 数据目录里面),取遇到的第一个 `mcp.json`; +2. 文档写明的默认位置 `~/.minimax/mcp.json`。 + +进程工作目录和 Node 可执行文件所在目录**刻意不查**,所以旁边恰好存在的无关 `mcp.json` 不可能抢到。布局仍然是未公开的:如果宿主把配置放在别处,插件会找不到服务器并显示空列表。 旧版本插件遗留在 `mcp.json` 旁边的凭据目录会被读取一次并导入 `context.dataDir`,之后不再写入。 @@ -70,29 +88,40 @@ | 文件 | 内容 | |---|---| | `mcp.json` | 只改某个服务器条目的 `Authorization` 头。改写是手术式的:文件其余部分逐字节不变。 | -| `/mcp-credentials/.json` | 凭证:client id、client secret、refresh_token、access_token、scope、过期时间 | -| `/mcp.json.bak` | 每次写入前对 `mcp.json` 的备份 | +| `/mcp-credentials/.json` | 凭证:client id、client secret、refresh_token、access_token、scope、过期时间。文件名就是服务器名,限定为 `A-Z a-z 0-9 . _ -` 且不超过 64 个字符,因此 `../../other` 这样的名字无法越出目录。 | +| `/settings.json` | 上面那两个设置。 | +| `/mcp.json.bak` | 每次写入前对 `mcp.json` 的备份。 | +| `/mcp.json.applied.bak` | **确认写入**前对 `mcp.json` 的备份。后台刷新写的是 `mcp.json.bak`,因此永远不会覆盖掉你确认时看到的那份快照。 | + +对 `mcp.json` 的写入是原子的:先在同目录写临时文件,再 rename。原始文件权限会被带过去(临时文件默认是默认权限)。写入被串行化,两次写入不会交错。写入失败时临时文件会被删除,原内容保持不变。**备份失败会中止写入**——备份是唯一的退路,所以它先于文件被写入,备份失败就什么都不改。 -对 `mcp.json` 的写入是原子的:先在同目录写临时文件,再 rename。原始文件权限会被带过去(临时文件默认是默认权限)。写入被串行化,两次写入不会交错。写入失败时临时文件会被删除,原内容保持不变。 +读取、改写、写入全程在同一把锁内完成,并且锁内会重新读一遍文件,因此并发编辑不会弄丢条目。 ### 出网主机 插件只访问两类主机: 1. MCP 配置里列出的 MCP 端点——用于探测和写入后的验活握手。 -2. 这些服务器的 OAuth 端点——取自它们的元数据:授权端点、令牌端点、受保护资源文档,以及 `.well-known` 的授权服务器元数据。 +2. 这些服务器的 OAuth 端点——取自它们的元数据:授权端点、令牌端点、设备授权端点、客户端注册端点、受保护资源文档,以及 `.well-known` 的授权服务器元数据。 除此之外不访问任何主机。没有埋点、没有分析服务、没有第三方服务。 -请求前会校验目标 URL:只允许 `https`(回环地址允许 `http`)、URL 里不允许带账号密码、实例元数据地址与链路本地地址一律拒绝。 +请求前会校验每一个 URL: + +- 只允许 `https`(回环地址允许 `http`); +- URL 里不允许带账号密码; +- 回环地址、RFC 1918(`10/8`、`172.16/12`、`192.168/16`)、运营商级 NAT(`100.64/10`)、链路本地(`169.254/16`)、IPv6 唯一本地(`fc00::/7`)、IPv6 链路本地(`fe80::/10`)、IPv6 组播、未指定地址,以及实例元数据主机,一律拒绝; +- IPv4-mapped、IPv4-compatible 与 NAT64 这几种 IPv6 形态会先还原出里面的 IPv4 地址,因此 `::ffff:127.0.0.1` 和 `::ffff:7f00:1` 同样被拒绝。 + +这里分两层:**远程服务器**回给你的 URL(元数据文档、令牌端点、设备端点)一律按最严格标准检查,永远够不到内网地址;**你自己配置**的 MCP 端点可以指向内网地址,但前提是打开了「允许内网地址」开关。 ### 启动的子进程 | 命令 | 时机 | |---|---| -| `explorer.exe `(Windows)或 `open `(macOS) | 每次授权一次,用于在默认浏览器打开同意页 | +| `explorer.exe `(Windows)、`open `(macOS)、`xdg-open `(Linux) | 每次授权一次,用于在默认浏览器打开同意页 | -参数以 argv 数组传入,绝不经过 shell。URL 是依据服务端自己的元数据拼出的授权请求。 +参数以 argv 数组传入,绝不经过 shell。URL 是依据服务端自己的元数据拼出的授权请求。启动器起不来时,页面会明说,并改为把链接给你复制。 ### 状态存放位置 @@ -102,10 +131,24 @@ 没有。本包没有声明 `agentAccess`,因此从不让 Agent 跑回合、读文件或调工具;一切都在 Node 进程内或授权服务器自己的页面里完成。 +### 读取的会话 + +没有。本包没有声明 `session.read`,因此看不到任何会话、回合或回复。 + +### 调用的工具 + +没有。本包没有声明 `toolAccess`,因此从不调用 MCP 或 Connector 工具。 + +### 麦克风 + +没有。本包没有声明 `deviceAccess`,因此从不录音。 + ### 凭据 令牌本体始终留在 Node 进程里。页面只拿到元信息:scope、过期时间,以及一个八位 SHA-256 指纹,用来分辨两个令牌是否不同。指纹不可逆,也不能当凭据用。凭据不会出现在 HTML、客户端 JavaScript、日志或错误响应中。 +内部错误码不会直接显示给用户。服务可能抛出的每一个码都登记在同一份目录里,由目录给出面向用户的文案;没登记的码会立刻失败,而不是漏出去。 + ## 测试环境 | | | @@ -123,7 +166,17 @@ - 需要凭证但没声明 OAuth 的服务器无法从页面登录,只能手工配置请求头。 - 授权码方式的回调地址要手工复制回来(宿主对回环端口有鉴权),除非授权服务器不提供设备码流程,否则优先用设备码。 - 探测只验证握手。服务端可能接受未认证的 `initialize`,却在调用工具时才要令牌。 -- 续期定时器只在 Mini App 运行时工作。App 停掉期间过期的令牌,会在下一次启动时补刷。 +- 自动续期只在你打开开关后才生效,而且只在 Mini App 运行期间工作。App 停掉期间过期的令牌,会在下一次启动时补刷。 + +## 开发 + +本包自带单元测试,在本目录下运行: + +```bash +node --test "tests/**/*.test.mjs" +``` + +覆盖 URL 守卫、凭据路径的目录约束、对 `mcp.json` 的手术式改写,以及一项静态检查:服务可能抛出的每一个错误码都必须已在目录中登记。 ## 许可证 diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/client/app.js b/plugins/microbiosis/mcp-oauth-console/miniapp/client/app.js index c50854aa..bba0737b 100644 --- a/plugins/microbiosis/mcp-oauth-console/miniapp/client/app.js +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/client/app.js @@ -12,6 +12,9 @@ const els = { hint: document.getElementById('panel-hint'), modalRoot: document.getElementById('modal-root'), toastRoot: document.getElementById('toast-root'), + configPath: document.getElementById('config-path-hint'), + autoRefresh: document.getElementById('toggle-auto-refresh'), + allowPrivate: document.getElementById('toggle-private-network'), themeToggle: document.getElementById('theme-toggle'), refreshAll: document.getElementById('refresh-all'), addServer: document.getElementById('add-server'), @@ -131,6 +134,13 @@ function renderKpis() { els.refreshable.textContent = String(summary.refreshable); els.expiring.textContent = String(summary.expiringSoon); els.expiring.className = summary.expiringSoon > 0 ? 'kpi__value kpi__value--warn' : 'kpi__value'; + + const settings = view.data.settings ?? {}; + if (els.autoRefresh) els.autoRefresh.checked = settings.autoRefresh === true; + if (els.allowPrivate) els.allowPrivate.checked = settings.allowPrivateNetwork === true; + if (els.configPath) { + els.configPath.textContent = view.data.configPath ? `将写入的文件:${view.data.configPath}` : '未能定位 MCP 配置文件'; + } } function renderServers() { @@ -677,7 +687,15 @@ function renderPkceBody(body, draft) { } const notice = element('div', 'notice'); - notice.append(element('p', null, '浏览器已打开授权页。如果未能自动跳转,请复制下面的链接在新标签打开:')); + notice.append( + element( + 'p', + null, + draft.session.browserOpened + ? '浏览器已打开授权页。如果未能自动跳转,请复制下面的链接在新标签打开:' + : '没能自动打开浏览器,请复制下面的链接到浏览器打开:', + ), + ); const link = element('a', 'btn btn--link', draft.session.authorizationUrl); link.href = draft.session.authorizationUrl; link.target = '_blank'; @@ -846,6 +864,7 @@ function openConfirmModal(serverName) { }; add('目标服务器', server?.name ?? serverName); add('MCP 地址', server?.url ?? '—'); + add('写入文件', view.data?.configPath ?? '—'); add('写入字段', `mcpServers.${serverName}.headers.Authorization`); add('新令牌指纹', server?.pendingFingerprint ?? '—'); add('当前指纹', server?.tokenFingerprint ?? '(尚未配置)'); @@ -855,7 +874,7 @@ function openConfirmModal(serverName) { element( 'div', 'notice notice--warn', - '这会修改本机 mcp.json 中该服务器的 Authorization 头,并在 mcp-credentials 下写入凭据文件。原文件会先备份为 mcp.json.bak。', + '这会修改上面列出的文件里该服务器的 Authorization 头,并在 dataDir 下写入凭据文件。写入前会先备份(备份失败则不写)。', ), ); }, @@ -866,7 +885,14 @@ function openConfirmModal(serverName) { className: 'btn btn--danger', onClick: async () => { try { - const result = await api('/api/apply', { method: 'POST', body: { serverName } }); + const result = await api('/api/apply', { + method: 'POST', + body: { + serverName, + confirmToken: server?.pendingConfirmToken ?? null, + expectedPath: view.data?.configPath ?? null, + }, + }); toast( `已写入 ${serverName}${result.probe ? ` · 握手状态 ${result.probe.status}` : ''}${result.created ? ' · 新建条目' : ''}`, ); @@ -971,6 +997,23 @@ els.refreshAll.addEventListener('click', async () => { els.addServer.addEventListener('click', () => openAuthorizeModal(null)); +async function saveSettings(patch) { + try { + await api('/api/settings', { method: 'POST', body: patch }); + await loadState(); + } catch (error) { + toast(`保存设置失败:${error.message}`, 'error'); + } +} + +els.autoRefresh?.addEventListener('change', (event) => { + void saveSettings({ autoRefresh: event.target.checked }); +}); + +els.allowPrivate?.addEventListener('change', (event) => { + void saveSettings({ allowPrivateNetwork: event.target.checked }); +}); + initTheme(); render(); void loadState(); diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/client/index.html b/plugins/microbiosis/mcp-oauth-console/miniapp/client/index.html index fb5f5288..b947c7c6 100644 --- a/plugins/microbiosis/mcp-oauth-console/miniapp/client/index.html +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/client/index.html @@ -47,6 +47,25 @@

MCP OAuth 令牌台

+
+
+

设置

+

—

+
+
+ +

默认关闭。关闭时只有手动点「刷新令牌」才会写配置,Mini App 停止期间不会有任何写入。

+ +

默认拒绝私有网段、链路本地与实例元数据地址。局域网里的 MCP 服务器需要打开它。

+
+
+

服务器

diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/client/styles.css b/plugins/microbiosis/mcp-oauth-console/miniapp/client/styles.css index d55b3d35..1b69e9c6 100644 --- a/plugins/microbiosis/mcp-oauth-console/miniapp/client/styles.css +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/client/styles.css @@ -277,6 +277,34 @@ main { color: var(--mcode-warning); } +.settings { + display: flex; + flex-direction: column; + gap: 4px; + padding: 16px 18px; +} + +.switch { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; + line-height: 22px; + cursor: pointer; +} + +.switch input { + width: 16px; + height: 16px; + accent-color: var(--mcode-accent); + cursor: pointer; +} + +.switch input:focus-visible { + outline: 2px solid var(--mcode-accent); + outline-offset: 2px; +} + /* ---------- badges ---------- */ .badge { diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/node/errors.mjs b/plugins/microbiosis/mcp-oauth-console/miniapp/node/errors.mjs new file mode 100644 index 00000000..ae3a196e --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/node/errors.mjs @@ -0,0 +1,97 @@ +// @ts-check + +/** + * Single source of truth for the domain error contract. + * + * Every failure the service can report is listed here with the HTTP status the + * transport should use and the Chinese text the page shows. The transport + * forwards `message` to the Client verbatim, so an error code is described in + * exactly one place instead of once per layer. + * + * `serviceError()` fails fast on an unregistered code, so a typo surfaces at + * the throw site instead of silently degrading into an opaque "internal error". + */ + +const ERROR_CATALOG = { + // request routing + invalid_json: { status: 400, message: '请求格式不正确' }, + internal_error: { status: 500, message: '服务端内部错误' }, + not_found: { status: 404, message: '接口不存在' }, + + // target selection + server_not_found: { status: 404, message: 'mcp.json 里没有这个服务器条目' }, + server_without_url: { status: 400, message: '该服务器是本地 stdio 进程,没有可检测的 URL' }, + target_url_required: { status: 400, message: '请先填写 MCP 地址' }, + target_url_invalid: { status: 400, message: 'MCP 地址格式不正确(必须是 https,或本机回环地址的 http)' }, + target_url_contains_credentials: { status: 400, message: 'MCP 地址里不能带用户名或密码' }, + target_url_blocked: { status: 400, message: '该地址属于实例元数据或链路本地网段,已拒绝访问' }, + invalid_server_name: { status: 400, message: '服务器名称含有非法字符(只允许字母、数字、点、下划线和连字符)' }, + config_path_changed: { status: 409, message: 'MCP 配置文件的位置已经变了,请刷新页面重新确认' }, + confirm_required: { status: 403, message: '写入需要页面上的确认令牌,请重新在页面上确认' }, + auto_refresh_disabled: { status: 409, message: '后台自动续期未开启,请先在页面上开启' }, + target_url_missing: { status: 400, message: '找不到该服务器的 URL' }, + + // authorization flow + session_not_found: { status: 404, message: '授权会话已失效,请重新发起授权' }, + state_mismatch: { status: 400, message: 'state 校验没通过,请重新发起授权' }, + authorization_code_missing: { status: 400, message: '回调里没有拿到 authorization code' }, + authorization_server_not_resolved: { status: 502, message: '定位不到该 MCP 地址对应的授权服务器' }, + authorization_endpoint_missing: { status: 502, message: '授权服务器元数据里缺少 authorization_endpoint' }, + dynamic_registration_unsupported: { status: 501, message: '该授权服务器不支持动态注册,需要手动提供 client_id' }, + device_flow_unsupported: { status: 501, message: '该授权服务器不支持设备码流程(device authorization)' }, + no_resource_metadata: { status: 502, message: '该地址需要 OAuth,但响应里没有 resource_metadata,无法自动发现授权服务器' }, + bearer_without_resource_metadata: { status: 502, message: '该地址要求 Bearer 凭证,但没有提供 resource_metadata,无法自动发现授权服务器' }, + credential_required_unknown_scheme: { status: 502, message: '该地址要求凭证,但没有声明认证方式(不是 OAuth),请手动配置 Authorization 头' }, + no_pending_grant: { status: 409, message: '没有待写入的令牌,请先完成授权' }, + + // token lifecycle + refresh_token_unavailable: { status: 409, message: '没有可用的 refresh_token,需要重新授权' }, + token_endpoint_unresolved: { status: 502, message: '拿不到令牌端点地址' }, + + // configuration patch + server_entry_not_locatable: { status: 409, message: '在 mcp.json 里定位不到该服务器条目' }, + patched_config_invalid: { status: 500, message: '改写后的配置不是合法 JSON,已放弃写入' }, + patch_verification_failed: { status: 500, message: '改写后的配置校验不通过,已放弃写入' }, + + // transport + request_failed: { status: 502, message: '请求授权服务器失败' }, + probe_failed: { status: 502, message: '探测该地址失败' }, + probe_indeterminate: { status: 502, message: '端点的响应无法判断是否需要 OAuth' }, +}; + +/** Codes whose tail is provider-controlled, so they cannot be enumerated. */ +const DYNAMIC_PREFIXES = [ + { prefix: 'token_request_failed_', status: 502, label: '授权服务器拒绝了令牌请求' }, + { prefix: 'device_authorization_failed_', status: 502, label: '设备码申请失败' }, + { prefix: 'dynamic_registration_failed_', status: 502, label: '动态注册失败' }, + { prefix: 'authorization_denied_', status: 400, label: '授权被拒绝' }, + { prefix: 'http_', status: 502, label: '请求失败' }, +]; + +export function isKnownServiceError(code) { + if (Object.prototype.hasOwnProperty.call(ERROR_CATALOG, code)) return true; + return DYNAMIC_PREFIXES.some((rule) => code.startsWith(rule.prefix)); +} + +/** Resolves a code into the status and the user-facing text. */ +export function describeServiceError(code) { + const known = Object.prototype.hasOwnProperty.call(ERROR_CATALOG, code) ? ERROR_CATALOG[code] : null; + if (known) return { code, status: known.status, message: known.message }; + for (const rule of DYNAMIC_PREFIXES) { + if (code.startsWith(rule.prefix)) { + return { code, status: rule.status, message: `${rule.label}(${code.slice(rule.prefix.length)})` }; + } + } + return { code, status: 500, message: `操作失败(${code})` }; +} + +/** Builds a domain error. An unregistered code is a programming error. */ +export function serviceError(code) { + if (!isKnownServiceError(code)) { + throw new Error(`unregistered_error_code:${code}`); + } + const error = new Error(code); + error.name = 'ServiceError'; + error.serviceCode = code; + return error; +} diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs b/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs index 5f2c7c52..18d84a7e 100644 --- a/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs @@ -14,9 +14,9 @@ import { spawn } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { basename, dirname, join, resolve } from 'node:path'; +import { basename, dirname, join, resolve, sep } from 'node:path'; import { serviceError } from './errors.mjs'; @@ -29,7 +29,6 @@ const REFRESH_WINDOW_MS = 30 * 60 * 1000; const DISCOVERY_TTL_MS = 10 * 60 * 1000; const FAILED_DISCOVERY_TTL_MS = 60 * 1000; const PROBE_TIMEOUT_MS = 20_000; -const GRANT_TTL_MS = 30 * 60 * 1000; /** Short, non-reversible label for a token. Lets the user compare old vs new. */ export function fingerprint(value) { @@ -125,6 +124,12 @@ function findMemberObjectSpan(text, key, from = 0, to = text.length) { return null; } +function findRootObjectSpan(text) { + const start = text.indexOf('{'); + if (start === -1) return null; + return { start, end: matchObjectSpan(text, start) }; +} + function findMemberStringSpan(text, key, from, to) { const needle = JSON.stringify(key); let index = text.indexOf(needle, from); @@ -147,34 +152,63 @@ function findMemberStringSpan(text, key, from, to) { /* ------------------------------------------------------------------ */ /** - * Locates the runtime `mcp.json`. The Host data dir normally lives inside the - * MiniMax data dir, but the lookup walks up from several roots so a different - * data-dir layout still resolves. + * Locates the runtime `mcp.json`. + * + * Only two sources are used: the Host's own data-dir chain (the plugin data + * dir lives inside the MiniMax data dir) and the documented default location. + * The process working directory and the Node executable's directory are + * deliberately not searched, so an unrelated `mcp.json` there can never win. + * + * The result is a candidate only. Before anything is written it is realpath'ed + * and shown in the page, and the write is bound to that exact path. */ -export function resolveMcpConfigPath(startDir) { - const override = process.env.MINIMAX_MCP_CONFIG; - if (override && existsSync(override)) return resolve(override); - - const roots = [ - startDir, - process.cwd(), - dirname(process.execPath), - ].filter(Boolean); - - for (const root of roots) { - let dir = resolve(root); - for (let depth = 0; depth < 10; depth += 1) { - const candidate = join(dir, 'mcp.json'); - if (existsSync(candidate)) return candidate; - const parent = dirname(dir); - if (parent === dir) break; - dir = parent; - } +export function resolveMcpConfigPath(dataDir) { + let dir = resolve(dataDir); + for (let depth = 0; depth < 10; depth += 1) { + const candidate = join(dir, 'mcp.json'); + if (existsSync(candidate)) return candidate; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; } - return join(homedir(), '.minimax', 'mcp.json'); } +/** The canonical path of the configuration file, with symlinks resolved. */ +export async function canonicalConfigPath(path) { + try { + return await realpath(path); + } catch { + return resolve(path); + } +} + +/** + * A server name becomes a file name, so it is restricted to a safe set. A + * name such as `../../other` must never be able to leave the credential + * directory. + */ +export function assertServerName(serverName) { + if ( + typeof serverName !== 'string' || + !/^[A-Za-z0-9._-]{1,64}$/.test(serverName) || + serverName === '.' || + serverName === '..' + ) { + throw serviceError('invalid_server_name'); + } + return serverName; +} + +/** The credential path for a server, proven to stay inside its directory. */ +export function credentialPath(serverName, dir) { + assertServerName(serverName); + const base = resolve(dir); + const path = resolve(join(base, `${serverName}.json`)); + if (!path.startsWith(base + sep)) throw serviceError('invalid_server_name'); + return path; +} + async function readConfigFile(path) { const text = await readFile(path, 'utf8'); const config = JSON.parse(text); @@ -188,14 +222,11 @@ async function writeJsonFile(path, value) { } async function backupConfig(path, backupPath) { - try { - const text = await readFile(path, 'utf8'); - await mkdir(dirname(backupPath), { recursive: true }); - await writeFileAtomic(backupPath, text); - return true; - } catch { - return false; - } + // A failed backup must abort the write: the backup is the only way back. + const text = await readFile(path, 'utf8'); + await mkdir(dirname(backupPath), { recursive: true }); + await writeFileAtomic(backupPath, text); + return true; } /** @@ -308,12 +339,114 @@ function probeRequest(url, { transport, token }) { return buildInitializeRequest(token); } +/** True for loopback, private, link-local, unique-local and metadata addresses. */ +export function isPrivateHost(hostname) { + const host = String(hostname).toLowerCase().replace(/^\[|\]$/g, ''); + if (host === 'localhost' || host.endsWith('.localhost')) return true; + if (host === '::1' || host === '::') return true; + if (host === '169.254.169.254' || host === 'metadata.google.internal' || host.endsWith('.internal')) return true; + + // IPv4, in any of the textual forms a URL may carry. + const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (ipv4) { + const parts = ipv4.slice(1).map(Number); + if (parts.some((part) => part > 255)) return true; + const [a, b] = parts; + if (a === 10 || a === 127 || a === 0) return true; + if (a === 192 && b === 168) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 169 && b === 254) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + return false; + } + + if (host.includes(':')) return isPrivateIpv6(host); + return false; +} + +/** + * Splits an IPv6 address into its eight groups. Both the dotted form + * (`::ffff:127.0.0.1`) and the hex form WHATWG URLs produce + * (`::ffff:7f00:1`) are accepted, because a URL parser rewrites the first one + * before the guard ever sees it. + */ +function parseIpv6Groups(text) { + if (!text) return []; + const groups = []; + for (const part of text.split(':')) { + if (part.includes('.')) { + const bytes = part.split('.'); + if (bytes.length !== 4) return null; + const numbers = bytes.map((byte) => (/^\d{1,3}$/.test(byte) ? Number(byte) : NaN)); + if (numbers.some((number) => !Number.isInteger(number) || number > 255)) return null; + groups.push((numbers[0] << 8) | numbers[1], (numbers[2] << 8) | numbers[3]); + continue; + } + if (!/^[0-9a-f]{1,4}$/.test(part)) return null; + groups.push(parseInt(part, 16)); + } + return groups; +} + +/** Expands an IPv6 address into its eight 16 bit groups, or null if malformed. */ +function expandIpv6(host) { + const halves = host.split('::'); + if (halves.length > 2) return null; + const head = parseIpv6Groups(halves[0]); + const tail = halves.length === 2 ? parseIpv6Groups(halves[1]) : []; + if (!head || !tail) return null; + if (halves.length === 1) return head.length === 8 ? head : null; + const missing = 8 - head.length - tail.length; + if (missing < 0) return null; + return [...head, ...new Array(missing).fill(0), ...tail]; +} + +/** + * Classifies an IPv6 address. Besides the reserved ranges it resolves the + * forms that carry an IPv4 address inside IPv6, because those are the way to + * smuggle a loopback or private target past a naive prefix check. + */ +function isPrivateIpv6(host) { + const groups = expandIpv6(host); + // An address we cannot parse is not one we may reach. + if (!groups) return true; + + const isZero = groups.every((group) => group === 0); + if (isZero) return true; + if (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) return true; + + const embeddedIpv4 = (high, low) => `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`; + const zeroPrefix = groups.slice(0, 5).every((group) => group === 0); + if (zeroPrefix && groups[5] === 0xffff) { + // IPv4-mapped: ::ffff:127.0.0.1 + return isPrivateHost(embeddedIpv4(groups[6], groups[7])); + } + if (groups.slice(0, 6).every((group) => group === 0) && (groups[6] !== 0 || groups[7] !== 0)) { + // IPv4-compatible: ::127.0.0.1 + return isPrivateHost(embeddedIpv4(groups[6], groups[7])); + } + if (groups[0] === 0x64 && groups[1] === 0xff9b && groups.slice(2, 6).every((group) => group === 0)) { + // NAT64: 64:ff9b::127.0.0.1 + return isPrivateHost(embeddedIpv4(groups[6], groups[7])); + } + if ((groups[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local + if ((groups[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link local + if ((groups[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast + return false; +} + /** * Validates a target before the process makes a request to it. The page is * untrusted input to the Node process, so a typed URL must not turn this Mini * App into an open proxy. + * + * `allowPrivateNetwork` is only for the MCP endpoint the user configured + * themselves; every URL that comes back from a remote server is checked + * strictly, because a hostile server could otherwise point us at the network + * around this machine. */ -export function assertTargetUrl(url) { +export function assertTargetUrl(url, options = {}) { + const { allowPrivateNetwork = false } = options; let parsed; try { parsed = new URL(url); @@ -326,13 +459,20 @@ export function assertTargetUrl(url) { if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) { throw serviceError('target_url_invalid'); } - // Instance metadata and link-local endpoints are never MCP servers. - if (host === '169.254.169.254' || host.endsWith('.internal') || host.startsWith('169.254.')) { - throw serviceError('target_url_blocked'); + if (isPrivateHost(host)) { + // A private address is acceptable only when the user pointed us at it. + if (!allowPrivateNetwork) throw serviceError('target_url_blocked'); } return parsed; } +/** Strict check for every URL that a remote server handed us. */ +export function assertMetadataUrl(url) { + const parsed = assertTargetUrl(url, { allowPrivateNetwork: false }); + if (parsed.protocol !== 'https:') throw serviceError('target_url_invalid'); + return parsed; +} + /** * Detects what an MCP endpoint demands and resolves the authorization server * metadata via the RFC 9728 protected-resource document. @@ -343,6 +483,9 @@ export function assertTargetUrl(url) { */ export async function discoverAuthorization(url, options = {}) { const transport = options.transport === 'sse' ? 'sse' : 'streamable-http'; + // The endpoint itself is the one the user configured, so a private address + // is allowed only when they opted into that. + assertTargetUrl(url, { allowPrivateNetwork: options.allowPrivateNetwork === true }); const probe = await httpJson(url, probeRequest(url, { transport }), PROBE_TIMEOUT_MS, 3, transport === 'sse'); if (probe.status !== 401) { // Only a 2xx proves the endpoint accepted an unauthenticated handshake. @@ -374,7 +517,7 @@ export async function discoverAuthorization(url, options = {}) { reason: bearer ? 'bearer_without_resource_metadata' : 'credential_required_unknown_scheme', }; } - const resourceDoc = await httpJson(resourceMatch[1], { headers: { accept: 'application/json' } }); + const resourceDoc = await httpJson(assertMetadataUrl(resourceMatch[1]).toString(), { headers: { accept: 'application/json' } }); const issuers = Array.isArray(resourceDoc.json?.authorization_servers) ? resourceDoc.json.authorization_servers : []; const metadata = await loadAuthorizationServerMetadata(issuers); return { @@ -393,6 +536,9 @@ async function loadAuthorizationServerMetadata(issuers) { const base = String(issuer).replace(/\/+$/, ''); for (const candidate of [`${base}/.well-known/oauth-authorization-server`, `${base}/.well-known/openid-configuration`]) { try { + // Every URL a remote server handed us is checked strictly: a hostile + // server must not be able to point us at the network around this host. + assertMetadataUrl(candidate); const response = await httpJson(candidate, { headers: { accept: 'application/json' } }); if (response.ok && response.json?.token_endpoint) return response.json; } catch { @@ -415,6 +561,7 @@ function pickAuthMethod(metadata) { async function registerClient(metadata, { redirectUri, scope, grantTypes }) { const endpoint = metadata?.registration_endpoint; if (!endpoint) throw serviceError('dynamic_registration_unsupported'); + assertMetadataUrl(endpoint); const supported = Array.isArray(metadata.grant_types_supported) ? metadata.grant_types_supported : []; const requested = grantTypes ?? (() => { const list = ['authorization_code']; @@ -467,6 +614,7 @@ function tokenRequestBody({ grantType, code, codeVerifier, redirectUri, refreshT } async function requestToken(metadata, client, form) { + assertMetadataUrl(metadata.token_endpoint); const headers = { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }; if (client.client_secret && client.auth_method === 'client_secret_basic') { headers.authorization = `Basic ${Buffer.from(`${client.client_id}:${client.client_secret}`).toString('base64')}`; @@ -502,13 +650,54 @@ export async function probeMcpEndpoint(url, token, transport) { /* ------------------------------------------------------------------ */ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { - const mcpConfigPath = resolveMcpConfigPath(dataDir); // Durable state belongs in the Host-owned dataDir. A credential directory // left next to mcp.json by an earlier version is read once and imported; // it is never written to again. const credentialDir = join(dataDir, 'mcp-credentials'); - const legacyCredentialDir = join(dirname(mcpConfigPath), 'mcp-credentials'); + const settingsPath = join(dataDir, 'settings.json'); const backupPath = join(dataDir, 'mcp.json.bak'); + const appliedBackupPath = join(dataDir, 'mcp.json.applied.bak'); + + // The configuration file is resolved once, symlinks resolved, and the page + // shows the exact path that a write would touch. + let configPathPromise = null; + const getConfigPath = async () => { + if (!configPathPromise) { + configPathPromise = canonicalConfigPath(resolveMcpConfigPath(dataDir)); + } + return configPathPromise; + }; + + let legacyCredentialDir = null; + const getLegacyCredentialDir = async () => { + if (!legacyCredentialDir) { + legacyCredentialDir = join(dirname(await getConfigPath()), 'mcp-credentials'); + } + return legacyCredentialDir; + }; + + const settings = { autoRefresh: false, allowPrivateNetwork: false }; + let settingsLoaded = false; + const loadSettings = async () => { + if (settingsLoaded) return settings; + settingsLoaded = true; + try { + const parsed = JSON.parse(await readFile(settingsPath, 'utf8')); + if (typeof parsed.autoRefresh === 'boolean') settings.autoRefresh = parsed.autoRefresh; + if (typeof parsed.allowPrivateNetwork === 'boolean') settings.allowPrivateNetwork = parsed.allowPrivateNetwork; + } catch { + // No settings file yet: the defaults apply. + } + return settings; + }; + const saveSettings = async (patch) => { + await loadSettings(); + for (const [key, value] of Object.entries(patch)) { + if (typeof value === 'boolean') settings[key] = value; + } + await writeJsonFile(settingsPath, settings); + return settings; + }; /** sessionId -> pending authorization state (server side only) */ const pending = new Map(); @@ -541,7 +730,7 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { async function listConfiguredServers() { let parsed; try { - parsed = await readConfigFile(mcpConfigPath); + parsed = await readConfigFile(await getConfigPath()); } catch (error) { return { servers: [], error: error instanceof Error ? error.message : 'config_unreadable' }; } @@ -567,7 +756,7 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { } async function loadCredential(serverName) { - const own = join(credentialDir, `${serverName}.json`); + const own = credentialPath(serverName, credentialDir); if (existsSync(own)) { try { return JSON.parse(await readFile(own, 'utf8')); @@ -577,7 +766,7 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { } // One-time import from the legacy directory: read-only, and the copy that // lands in dataDir becomes the only copy this app maintains. - const legacy = join(legacyCredentialDir, `${serverName}.json`); + const legacy = credentialPath(serverName, await getLegacyCredentialDir()); if (!existsSync(legacy)) return null; try { const data = JSON.parse(await readFile(legacy, 'utf8')); @@ -590,7 +779,7 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { } async function saveCredential(serverName, data) { - const path = join(credentialDir, `${serverName}.json`); + const path = credentialPath(serverName, credentialDir); await writeJsonFile(path, data); return path; } @@ -598,8 +787,9 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { async function probeDiscovery(serverName, url, transport) { const cached = discoveryCache.get(serverName); if (cached && Date.now() - cached.at < cached.ttlMs) return cached.value; + const { allowPrivateNetwork } = await loadSettings(); try { - const value = await discoverAuthorization(url, { transport }); + const value = await discoverAuthorization(url, { transport, allowPrivateNetwork }); discoveryCache.set(serverName, { at: Date.now(), ttlMs: DISCOVERY_TTL_MS, value }); return value; } catch (error) { @@ -686,6 +876,7 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { warnings, pendingGrant: Boolean(grant), pendingFingerprint: grant ? fingerprint(grant.token.access_token) : null, + pendingConfirmToken: grant?.confirmToken ?? null, lastError: grant?.error ?? null, }); } @@ -693,6 +884,8 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { return { now, configError, + configPath: await getConfigPath(), + settings: await loadSettings(), servers: items, summary: { total: items.length, @@ -703,17 +896,31 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { }; } - function openInBrowser(url) { + /** + * Opens a URL in the default browser. The platform helper differs, and on + * Linux `open` does not exist at all, so a failed spawn is reported instead + * of being swallowed - the page then tells the user to copy the link. + */ + async function openInBrowser(url) { + const command = + process.platform === 'win32' ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open'; try { // Arguments are passed as an argv array and never through a shell. - const isWindows = process.platform === 'win32'; - const child = isWindows - ? spawn('explorer.exe', [url], { stdio: 'ignore' }) - : spawn('open', [url], { stdio: 'ignore' }); + const child = spawn(command, [url], { stdio: 'ignore' }); children.add(child); child.once('exit', () => children.delete(child)); + const started = await new Promise((resolve) => { + let settled = false; + const done = (value) => { + if (settled) return; + settled = true; + resolve(value); + }; + child.once('spawn', () => done(true)); + child.once('error', () => done(false)); + }); child.unref(); - return true; + return started; } catch { return false; } @@ -770,7 +977,7 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { createdAt: Date.now(), }); - const opened = openInBrowser(authorizationUrl); + const opened = await openInBrowser(authorizationUrl); log('info', `oauth.authorization_started server=${serverName} browser_opened=${opened}`); return { sessionId, @@ -795,7 +1002,9 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { if (error) throw serviceError(`authorization_denied_${error}`); } if (!code) throw serviceError('authorization_code_missing'); - if (state && state !== session.state) throw serviceError('state_mismatch'); + // The state must match exactly: a pasted URL without one is not a + // callback for this session. + if (state !== session.state) throw serviceError('state_mismatch'); const form = tokenRequestBody({ grantType: 'authorization_code', @@ -816,6 +1025,9 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { client: session.client, scope: session.scope, createdAt: Date.now(), + // A one-time token, so a write to the Host configuration always goes + // through the confirmation the page showed. + confirmToken: randomBytes(16).toString('hex'), }; awaitingConfirmation.set(session.serverName, grant); log('info', `oauth.authorization_completed server=${session.serverName}`); @@ -867,6 +1079,7 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { client: session.client, scope: session.scope, createdAt: Date.now(), + confirmToken: randomBytes(16).toString('hex'), }); log('info', `oauth.callback_received server=${session.serverName}`); return { ok: true, serverName: session.serverName, message: '授权成功,请回到 Mini App 确认写入配置。' }; @@ -879,9 +1092,13 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { } /** Commits a pending grant: credential file first, then the mcp.json patch. */ - async function applyGrant(serverName) { + async function applyGrant(serverName, options = {}) { + const { confirmToken = null, expectedPath = null } = options; const grant = awaitingConfirmation.get(serverName); if (!grant) throw serviceError('no_pending_grant'); + // A write to the Host configuration only happens through the confirmation + // the page showed, so the page has to present the one-time token. + if (!confirmToken || confirmToken !== grant.confirmToken) throw serviceError('confirm_required'); const token = grant.token; const expiresAt = readTokenExpiry(token.access_token); const credential = { @@ -898,7 +1115,11 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { note: 'Managed by MCP OAuth Console', }; await saveCredential(serverName, credential); - const patch = await patchMcpAuthorization(serverName, token.access_token, { url: grant.url }); + const patch = await patchMcpAuthorization(serverName, token.access_token, { + url: grant.url, + confirmed: true, + expectedPath, + }); awaitingConfirmation.delete(serverName); const { servers: configured } = await listConfiguredServers(); const transport = configured.find((item) => item.name === serverName)?.type; @@ -981,71 +1202,91 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { /** * Patches `mcpServers..headers.Authorization` in place, keeping the * rest of the file byte-identical. Creates the entry when it is missing. + * + * The whole read-modify-write runs under one lock and the file is re-read + * inside it, so a concurrent edit cannot lose entries. The path the write + * targets must match the one the user confirmed. */ - async function patchMcpAuthorization(serverName, token, { url } = {}) { + async function patchMcpAuthorization(serverName, token, options = {}) { + const { url, confirmed = false, expectedPath = null } = options; + assertServerName(serverName); const bearer = `Bearer ${token}`; - const parsed = await readConfigFile(mcpConfigPath); - const existing = parsed.servers[serverName]; - - if (!existing) { - const next = { ...parsed.config }; - next.mcpServers = { - ...parsed.servers, - [serverName]: { + + return withWriteLock(async () => { + const configPath = await getConfigPath(); + if (expectedPath && resolve(expectedPath) !== resolve(configPath)) { + throw serviceError('config_path_changed'); + } + const parsed = await readConfigFile(configPath); + const rootSpan = findRootObjectSpan(parsed.text); + if (!rootSpan) throw serviceError('server_entry_not_locatable'); + const serversSpan = findMemberObjectSpan(parsed.text, 'mcpServers', rootSpan.start, rootSpan.end); + if (!serversSpan) throw serviceError('server_entry_not_locatable'); + const existing = parsed.servers[serverName]; + + let nextText; + let replaced = false; + let created = false; + let previous = ''; + + if (!existing) { + // Insert the entry textually, so the rest of the file stays + // byte-identical instead of being re-serialized. + const entry = `${JSON.stringify(serverName)}: ${JSON.stringify({ type: 'streamable-http', enabled: true, configured: true, builtin: false, url: url ?? '', headers: { Authorization: bearer }, - }, - }; - return withWriteLock(async () => { - await backupConfig(mcpConfigPath, backupPath); - await writeJsonFile(mcpConfigPath, next); - return { created: true, replaced: false, previousFingerprint: null }; - }); - } - const headers = existing.headers && typeof existing.headers === 'object' ? existing.headers : null; - const previous = headers && typeof headers.Authorization === 'string' ? headers.Authorization : ''; - const span = findMemberObjectSpan(parsed.text, serverName); - if (!span) throw serviceError('server_entry_not_locatable'); - - let nextText; - let replaced = false; - const authSpan = findMemberStringSpan(parsed.text, 'Authorization', span.start, span.end); - if (authSpan) { - // Replace the existing Authorization value in place. - nextText = `${parsed.text.slice(0, authSpan.start)}${JSON.stringify(bearer)}${parsed.text.slice(authSpan.end)}`; - replaced = true; - } else { - // The entry carries no Authorization header yet - it may authenticate with - // x-api-key, or not at all. Add the header instead of refusing the write - // after the user already completed the browser authorization. - const headersSpan = headers ? findMemberObjectSpan(parsed.text, 'headers', span.start, span.end) : null; - if (headersSpan) { - const insertAt = headersSpan.start + 1; - nextText = `${parsed.text.slice(0, insertAt)}${JSON.stringify('Authorization')}: ${JSON.stringify(bearer)}, ${parsed.text.slice(insertAt)}`; + })}`; + const insertAt = serversSpan.end - 1; + const head = parsed.text.slice(0, insertAt).trimEnd(); + const tail = parsed.text.slice(insertAt); + const separator = head.endsWith('{') ? '' : ','; + nextText = `${head}${separator}\n ${entry}\n ${tail}`; + created = true; } else { - const insertAt = span.start + 1; - nextText = `${parsed.text.slice(0, insertAt)}${JSON.stringify('headers')}: { ${JSON.stringify('Authorization')}: ${JSON.stringify(bearer)} }, ${parsed.text.slice(insertAt)}`; + const span = findMemberObjectSpan(parsed.text, serverName, serversSpan.start, serversSpan.end); + if (!span) throw serviceError('server_entry_not_locatable'); + const headers = existing.headers && typeof existing.headers === 'object' ? existing.headers : null; + previous = headers && typeof headers.Authorization === 'string' ? headers.Authorization : ''; + const authSpan = findMemberStringSpan(parsed.text, 'Authorization', span.start, span.end); + if (authSpan) { + // Replace the existing Authorization value in place. + nextText = `${parsed.text.slice(0, authSpan.start)}${JSON.stringify(bearer)}${parsed.text.slice(authSpan.end)}`; + replaced = true; + } else { + // The entry carries no Authorization header yet - it may authenticate + // with x-api-key, or not at all. Add the header instead of refusing + // the write after the user already completed the authorization. + const headersSpan = headers ? findMemberObjectSpan(parsed.text, 'headers', span.start, span.end) : null; + if (headersSpan) { + const insertAt = headersSpan.start + 1; + nextText = `${parsed.text.slice(0, insertAt)}${JSON.stringify('Authorization')}: ${JSON.stringify(bearer)}, ${parsed.text.slice(insertAt)}`; + } else { + const insertAt = span.start + 1; + nextText = `${parsed.text.slice(0, insertAt)}${JSON.stringify('headers')}: { ${JSON.stringify('Authorization')}: ${JSON.stringify(bearer)} }, ${parsed.text.slice(insertAt)}`; + } + } } - } - let verified; - try { - verified = JSON.parse(nextText); - } catch { - throw serviceError('patched_config_invalid'); - } - if (verified?.mcpServers?.[serverName]?.headers?.Authorization !== bearer) { - throw serviceError('patch_verification_failed'); - } - return withWriteLock(async () => { - await backupConfig(mcpConfigPath, backupPath); - await writeFileAtomic(mcpConfigPath, nextText); + let verified; + try { + verified = JSON.parse(nextText); + } catch { + throw serviceError('patched_config_invalid'); + } + if (verified?.mcpServers?.[serverName]?.headers?.Authorization !== bearer) { + throw serviceError('patch_verification_failed'); + } + // The backup is the only way back, so a failed backup aborts the write. + // A confirmed apply keeps its own copy, so a background refresh cannot + // replace the state the user was shown. + await backupConfig(configPath, confirmed ? appliedBackupPath : backupPath); + await writeFileAtomic(configPath, nextText); return { - created: false, + created, replaced, previousFingerprint: previous ? fingerprint(previous.replace(/^\S+\s+/, '')) : null, }; @@ -1053,7 +1294,11 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { } /** Refreshes every server whose token is inside the refresh window. */ - async function refreshExpiring() { + async function refreshExpiring({ force = false } = {}) { + const { autoRefresh } = await loadSettings(); + if (!autoRefresh && !force) { + return []; + } const { servers } = await listConfiguredServers(); const results = []; for (const server of servers) { @@ -1114,7 +1359,7 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { if (client.client_secret && authMethod === 'client_secret_basic') { deviceHeaders.authorization = `Basic ${Buffer.from(`${client.client_id}:${client.client_secret}`).toString('base64')}`; } - const response = await httpJson(metadata.device_authorization_endpoint, { + const response = await httpJson(assertMetadataUrl(metadata.device_authorization_endpoint).toString(), { method: 'POST', headers: deviceHeaders, body: form.toString(), @@ -1200,6 +1445,7 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { client: session.client, scope: session.scope, createdAt: Date.now(), + confirmToken: randomBytes(16).toString('hex'), }); log('info', `oauth.device_authorized server=${session.serverName}`); onChange?.(); @@ -1257,7 +1503,9 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { } return { - mcpConfigPath, + getConfigPath, + loadSettings, + saveSettings, credentialDir, listConfiguredServers, buildState, diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs b/plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs index abfb32de..3e0162fc 100644 --- a/plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs @@ -259,12 +259,30 @@ export async function start(context) { fail(response, 'invalid_json'); return; } - const result = await service.applyGrant(String(body.serverName ?? '')); + const result = await service.applyGrant(String(body.serverName ?? ''), { + confirmToken: body.confirmToken ? String(body.confirmToken) : null, + expectedPath: body.expectedPath ? String(body.expectedPath) : null, + }); broadcast('state', { reason: 'applied' }); sendJson(response, 200, { ok: true, ...result }); return; } + if (method === 'POST' && pathname === '/api/settings') { + const body = await readBody(request); + if (!body) { + fail(response, 'invalid_json'); + return; + } + const settings = await service.saveSettings({ + autoRefresh: body.autoRefresh === true, + allowPrivateNetwork: body.allowPrivateNetwork === true, + }); + broadcast('state', { reason: 'settings' }); + sendJson(response, 200, { ok: true, settings }); + return; + } + if (method === 'POST' && pathname === '/api/refresh') { const body = await readBody(request); if (!body) { @@ -278,7 +296,9 @@ export async function start(context) { } if (method === 'POST' && pathname === '/api/refresh-all') { - const results = await service.refreshExpiring(); + // The user pressed the button, so this one is confirmed even when the + // background timer is off. + const results = await service.refreshExpiring({ force: true }); broadcast('state', { reason: 'refreshed' }); sendJson(response, 200, { ok: true, results }); return; @@ -355,10 +375,22 @@ export async function start(context) { return { dispose }; } +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[char]); +} + function renderCallbackPage(result) { const ok = result.ok; - const title = ok ? '授权完成' : '授权未完成'; - const detail = result.message ?? ''; + // The error text can come from a crafted callback URL, so it is escaped + // before it reaches the page. + const title = escapeHtml(ok ? '授权完成' : '授权未完成'); + const detail = escapeHtml(result.message ?? ''); const tone = ok ? 'var(--mcode-success)' : 'var(--mcode-danger)'; return ` diff --git a/plugins/microbiosis/mcp-oauth-console/tests/authorization-patch.test.mjs b/plugins/microbiosis/mcp-oauth-console/tests/authorization-patch.test.mjs new file mode 100644 index 00000000..066cdb26 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/tests/authorization-patch.test.mjs @@ -0,0 +1,101 @@ +// The Host MCP configuration is written surgically: only the Authorization +// header of the target entry changes, and the rest of the file stays +// byte-identical. +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { createOAuthService } from '../miniapp/node/oauth-service.mjs'; + +const CONFIG = `{ + "mcpServers": { + "with-auth": { + "type": "streamable-http", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer old-token" + } + }, + "with-other-key": { + "type": "streamable-http", + "url": "https://example.org/mcp", + "headers": { + "x-api-key": "secret-key" + } + }, + "without-headers": { + "type": "streamable-http", + "url": "https://example.net/mcp" + } + }, + "otherTopLevel": { + "Authorization": "Bearer must-not-be-touched" + } +} +`; + +async function makeService() { + const root = await mkdtemp(join(tmpdir(), 'mcp-oauth-console-patch-')); + const dataDir = join(root, 'data'); + await writeFile(join(root, 'mcp.json'), CONFIG, 'utf8'); + const service = createOAuthService({ dataDir, baseUrl: 'http://127.0.0.1:39999', logger: { debug() {}, info() {}, warn() {}, error() {} } }); + return { root, service }; +} + +test('an existing Authorization header is replaced in place', async () => { + const { root, service } = await makeService(); + const before = await readFile(join(root, 'mcp.json'), 'utf8'); + const result = await service.writeAuthorization('with-auth', 'new-token', { url: 'https://example.com/mcp' }); + const after = await readFile(join(root, 'mcp.json'), 'utf8'); + + assert.equal(result.replaced, true); + assert.equal(result.created, false); + assert.ok(after.includes('"Bearer new-token"')); + assert.ok(!after.includes('Bearer old-token')); + const changed = before.split('\n').filter((line, index) => line !== after.split('\n')[index]); + assert.equal(changed.length, 1, 'exactly one line may change'); + service.dispose(); +}); + +test('an entry with another key keeps it and gains an Authorization header', async () => { + const { root, service } = await makeService(); + const before = await readFile(join(root, 'mcp.json'), 'utf8'); + await service.writeAuthorization('with-other-key', 'new-token', { url: 'https://example.org/mcp' }); + const after = await readFile(join(root, 'mcp.json'), 'utf8'); + + assert.ok(after.includes('"x-api-key": "secret-key"'), 'the existing key must survive'); + assert.ok(after.includes('"Authorization": "Bearer new-token"')); + const changed = before.split('\n').filter((line, index) => line !== after.split('\n')[index]); + assert.ok(changed.length <= 2, `only the headers block may change, got ${changed.length} lines`); + service.dispose(); +}); + +test('an entry without headers gains one, and unrelated entries stay untouched', async () => { + const { root, service } = await makeService(); + const before = await readFile(join(root, 'mcp.json'), 'utf8'); + await service.writeAuthorization('without-headers', 'new-token', { url: 'https://example.net/mcp' }); + const after = await readFile(join(root, 'mcp.json'), 'utf8'); + + assert.ok(after.includes('"headers"')); + assert.ok(after.includes('"Authorization": "Bearer new-token"')); + assert.ok(after.includes('"Bearer must-not-be-touched"'), 'another top level key must stay'); + assert.ok(before.includes('"Bearer must-not-be-touched"')); + service.dispose(); +}); + +test('a new entry is inserted without rewriting the rest of the file', async () => { + const { root, service } = await makeService(); + const before = await readFile(join(root, 'mcp.json'), 'utf8'); + const result = await service.writeAuthorization('brand-new', 'new-token', { url: 'https://example.dev/mcp' }); + const after = await readFile(join(root, 'mcp.json'), 'utf8'); + + assert.equal(result.created, true); + assert.ok(after.includes('"brand-new"')); + assert.ok(after.includes('"Bearer must-not-be-touched"')); + const parsed = JSON.parse(after); + assert.equal(parsed.mcpServers['brand-new'].headers.Authorization, 'Bearer new-token'); + assert.equal(parsed.mcpServers['with-auth'].headers.Authorization, 'Bearer old-token'); + service.dispose(); +}); diff --git a/plugins/microbiosis/mcp-oauth-console/tests/credential-path.test.mjs b/plugins/microbiosis/mcp-oauth-console/tests/credential-path.test.mjs new file mode 100644 index 00000000..0b5288a2 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/tests/credential-path.test.mjs @@ -0,0 +1,30 @@ +// A server name becomes a file name, so it must never be able to leave the +// credential directory. +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep } from 'node:path'; +import { test } from 'node:test'; + +import { assertServerName, credentialPath } from '../miniapp/node/oauth-service.mjs'; + +const dir = join(tmpdir(), 'mcp-oauth-console-credential-test'); + +test('ordinary names are accepted', () => { + for (const name of ['hf-endpoints', 'exa', 'my.server', 'a_b-c']) { + assert.equal(assertServerName(name), name); + const path = credentialPath(name, dir); + assert.ok(path.startsWith(resolve(dir) + sep)); + } +}); + +test('names that could walk out of the directory are refused', () => { + for (const name of ['..', '.', '../../other', 'a/b', 'a\\b', '', 'x'.repeat(65), 'a b']) { + assert.throws(() => assertServerName(name), undefined, `expected a refusal for ${JSON.stringify(name)}`); + assert.throws(() => credentialPath(name, dir), undefined, `expected a refusal for ${JSON.stringify(name)}`); + } +}); + +test('the resolved path always stays inside the directory', () => { + const path = credentialPath('hf-endpoints', dir); + assert.equal(path, join(resolve(dir), 'hf-endpoints.json')); +}); diff --git a/plugins/microbiosis/mcp-oauth-console/tests/entry-loads.test.mjs b/plugins/microbiosis/mcp-oauth-console/tests/entry-loads.test.mjs new file mode 100644 index 00000000..399bd370 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/tests/entry-loads.test.mjs @@ -0,0 +1,29 @@ +// The Node entry and its modules must load. A module that is imported but not +// shipped leaves the Mini App unable to start, and no manifest check notices. +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +test('the node entry and its modules load', async () => { + const entry = await import('../miniapp/node/server.mjs'); + assert.equal(typeof entry.start, 'function'); + + const service = await import('../miniapp/node/oauth-service.mjs'); + assert.equal(typeof service.createOAuthService, 'function'); + assert.equal(typeof service.assertTargetUrl, 'function'); + assert.equal(typeof service.credentialPath, 'function'); + + const errors = await import('../miniapp/node/errors.mjs'); + assert.equal(typeof errors.serviceError, 'function'); + assert.equal(typeof errors.describeServiceError, 'function'); +}); + +test('every code the service throws is registered in the catalog', async () => { + const { readFile } = await import('node:fs/promises'); + const source = await readFile(new URL('../miniapp/node/oauth-service.mjs', import.meta.url), 'utf8'); + const { isKnownServiceError } = await import('../miniapp/node/errors.mjs'); + + const used = [...source.matchAll(/serviceError\(\s*'([a-z0-9_]+)'/g)].map((m) => m[1]); + assert.ok(used.length > 0, 'expected the service to throw catalogued codes'); + const unknown = [...new Set(used)].filter((code) => !isKnownServiceError(code)); + assert.deepEqual(unknown, [], `unregistered error codes: ${unknown.join(', ')}`); +}); diff --git a/plugins/microbiosis/mcp-oauth-console/tests/url-guard.test.mjs b/plugins/microbiosis/mcp-oauth-console/tests/url-guard.test.mjs new file mode 100644 index 00000000..0f7a64b0 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/tests/url-guard.test.mjs @@ -0,0 +1,60 @@ +// One guard for every outbound request. A hostile server must not be able to +// point this Mini App at the network around the machine. +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { assertMetadataUrl, assertTargetUrl, isPrivateHost } from '../miniapp/node/oauth-service.mjs'; + +const blocked = [ + ['http://example.com/mcp', 'plain http'], + ['https://user:pass@example.com/mcp', 'credentials in the url'], + ['https://10.1.2.3/mcp', 'rfc1918 10/8'], + ['https://192.168.0.5/mcp', 'rfc1918 192.168/16'], + ['https://172.16.9.9/mcp', 'rfc1918 172.16/12'], + ['https://127.0.0.1/mcp', 'loopback'], + ['https://169.254.169.254/latest/meta-data', 'instance metadata'], + ['https://169.254.10.10/mcp', 'link local'], + ['https://[::1]/mcp', 'ipv6 loopback'], + ['https://[::ffff:127.0.0.1]/mcp', 'ipv4 mapped loopback'], + ['https://[::ffff:7f00:1]/mcp', 'ipv4 mapped loopback in the hex form a url parser produces'], + ['https://[::ffff:a00:1]/mcp', 'ipv4 mapped rfc1918'], + ['https://[64:ff9b::7f00:1]/mcp', 'nat64 loopback'], + ['https://[fd00::1]/mcp', 'ipv6 unique local'], + ['https://[fe80::1]/mcp', 'ipv6 link local'], + ['https://metadata.google.internal/mcp', 'gcp metadata'], +]; + +test('private and metadata targets are refused', () => { + for (const [url, label] of blocked) { + assert.throws(() => assertTargetUrl(url), undefined, `expected a refusal for ${label}: ${url}`); + assert.throws(() => assertMetadataUrl(url), undefined, `expected a refusal for ${label}: ${url}`); + } +}); + +test('a remote url that carries a private address is refused even when private networks are allowed', () => { + assert.throws( + () => assertMetadataUrl('https://10.0.0.5/oauth/token'), + undefined, + 'metadata urls must never reach a private address', + ); +}); + +test('https public targets pass', () => { + assert.ok(assertTargetUrl('https://endpoints.huggingface.co/mcp')); + assert.ok(assertMetadataUrl('https://huggingface.co/.well-known/oauth-authorization-server')); +}); + +test('the user may opt into private addresses for their own endpoint', () => { + assert.ok(assertTargetUrl('http://127.0.0.1:3000/mcp', { allowPrivateNetwork: true })); + assert.ok(assertTargetUrl('https://192.168.1.10/mcp', { allowPrivateNetwork: true })); +}); + +test('isPrivateHost classifies the usual ranges', () => { + assert.equal(isPrivateHost('127.0.0.1'), true); + assert.equal(isPrivateHost('::ffff:10.0.0.1'), true); + assert.equal(isPrivateHost('::ffff:7f00:1'), true); + assert.equal(isPrivateHost('64:ff9b::7f00:1'), true); + assert.equal(isPrivateHost('::ffff:808:808'), false); + assert.equal(isPrivateHost('example.com'), false); + assert.equal(isPrivateHost('huggingface.co'), false); +}); From 55be487788ed3664067b7957abc358f82c6b7bf0 Mon Sep 17 00:00:00 2001 From: Microbiosis <86361982+Microbiosis@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:10:11 +0800 Subject: [PATCH 4/5] Close the redirect gap and keep internal codes off the page - follow redirects by hand and check every hop again with the same rule, so a 302 cannot walk past the URL guard - resolve IPv4-mapped, IPv4-compatible and NAT64 IPv6 addresses back to their IPv4 address before the private-range check - let the configured MCP endpoint use a private address when the user opted into it, in the authorize and device paths as well as the probe - check the authorization endpoint too, so the browser is never sent to an address a hostile server chose - map an unregistered error code to the generic one before it reaches the page, and route the remaining raw throws through the catalog - add tests for the redirect guard and for the error contract --- .../microbiosis/mcp-oauth-console/README.md | 2 + .../mcp-oauth-console/README.zh-CN.md | 2 + .../mcp-oauth-console/miniapp/node/errors.mjs | 3 + .../miniapp/node/oauth-service.mjs | 192 +++++++++++++++--- .../mcp-oauth-console/miniapp/node/server.mjs | 4 +- .../tests/entry-loads.test.mjs | 17 ++ .../tests/redirect-guard.test.mjs | 118 +++++++++++ 7 files changed, 307 insertions(+), 31 deletions(-) create mode 100644 plugins/microbiosis/mcp-oauth-console/tests/redirect-guard.test.mjs diff --git a/plugins/microbiosis/mcp-oauth-console/README.md b/plugins/microbiosis/mcp-oauth-console/README.md index b231dd2f..b763a942 100644 --- a/plugins/microbiosis/mcp-oauth-console/README.md +++ b/plugins/microbiosis/mcp-oauth-console/README.md @@ -113,6 +113,8 @@ Every URL is validated before the process requests it: - loopback, RFC 1918 (`10/8`, `172.16/12`, `192.168/16`), carrier-grade NAT (`100.64/10`), link-local (`169.254/16`), IPv6 unique-local (`fc00::/7`), IPv6 link-local (`fe80::/10`), IPv6 multicast, the unspecified address, and the instance-metadata hosts are refused; - IPv4-mapped, IPv4-compatible and NAT64 IPv6 forms are resolved back to their IPv4 address first, so `::ffff:127.0.0.1` and `::ffff:7f00:1` are refused as well. +Redirects are followed by hand, and **every hop is checked again with the same rule** — a `302` must not be a way around the guard. At most five hops are followed. + Two tiers exist. A URL that a **remote server** handed back — a metadata document, a token endpoint, a device endpoint — is always checked strictly and can never reach a private address. A URL the **user** configured as their own MCP endpoint may point at a private address, but only when the "Allow private addresses" switch is on. ### Spawned processes diff --git a/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md b/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md index cb194ffe..06736231 100644 --- a/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md +++ b/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md @@ -113,6 +113,8 @@ - 回环地址、RFC 1918(`10/8`、`172.16/12`、`192.168/16`)、运营商级 NAT(`100.64/10`)、链路本地(`169.254/16`)、IPv6 唯一本地(`fc00::/7`)、IPv6 链路本地(`fe80::/10`)、IPv6 组播、未指定地址,以及实例元数据主机,一律拒绝; - IPv4-mapped、IPv4-compatible 与 NAT64 这几种 IPv6 形态会先还原出里面的 IPv4 地址,因此 `::ffff:127.0.0.1` 和 `::ffff:7f00:1` 同样被拒绝。 +重定向是**手动跟随**的,**每一跳都用同一条规则重新检查**——`302` 不能成为绕过守卫的路径。最多跟随 5 跳。 + 这里分两层:**远程服务器**回给你的 URL(元数据文档、令牌端点、设备端点)一律按最严格标准检查,永远够不到内网地址;**你自己配置**的 MCP 端点可以指向内网地址,但前提是打开了「允许内网地址」开关。 ### 启动的子进程 diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/node/errors.mjs b/plugins/microbiosis/mcp-oauth-console/miniapp/node/errors.mjs index ae3a196e..3fe3cc65 100644 --- a/plugins/microbiosis/mcp-oauth-console/miniapp/node/errors.mjs +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/node/errors.mjs @@ -25,6 +25,9 @@ const ERROR_CATALOG = { target_url_invalid: { status: 400, message: 'MCP 地址格式不正确(必须是 https,或本机回环地址的 http)' }, target_url_contains_credentials: { status: 400, message: 'MCP 地址里不能带用户名或密码' }, target_url_blocked: { status: 400, message: '该地址属于实例元数据或链路本地网段,已拒绝访问' }, + redirect_invalid: { status: 400, message: '服务端返回的重定向地址无法解析,已停止请求' }, + too_many_redirects: { status: 502, message: '重定向次数过多,已停止请求' }, + config_not_parseable: { status: 500, message: 'mcp.json 的内容无法解析,已放弃写入' }, invalid_server_name: { status: 400, message: '服务器名称含有非法字符(只允许字母、数字、点、下划线和连字符)' }, config_path_changed: { status: 409, message: 'MCP 配置文件的位置已经变了,请刷新页面重新确认' }, confirm_required: { status: 403, message: '写入需要页面上的确认令牌,请重新在页面上确认' }, diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs b/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs index 18d84a7e..4d093e32 100644 --- a/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs @@ -29,6 +29,8 @@ const REFRESH_WINDOW_MS = 30 * 60 * 1000; const DISCOVERY_TTL_MS = 10 * 60 * 1000; const FAILED_DISCOVERY_TTL_MS = 60 * 1000; const PROBE_TIMEOUT_MS = 20_000; +/** How many redirect hops a request may take before it is refused. */ +const MAX_REDIRECT_HOPS = 5; /** Short, non-reversible label for a token. Lets the user compare old vs new. */ export function fingerprint(value) { @@ -64,7 +66,7 @@ function skipWhitespace(text, index) { } function readJsonString(text, index) { - if (text[index] !== '"') throw new Error('expected_string'); + if (text[index] !== '"') throw serviceError('config_not_parseable'); let i = index + 1; let value = ''; while (i < text.length) { @@ -85,7 +87,7 @@ function readJsonString(text, index) { value += ch; i += 1; } - throw new Error('unterminated_string'); + throw serviceError('config_not_parseable'); } function matchObjectSpan(text, start) { @@ -104,7 +106,7 @@ function matchObjectSpan(text, start) { } i += 1; } - throw new Error('unterminated_object'); + throw serviceError('config_not_parseable'); } function findMemberObjectSpan(text, key, from = 0, to = text.length) { @@ -258,18 +260,63 @@ function delay(ms) { }); } +/** + * Resolves a redirect target and checks it with the same rule as the URL the + * request started from. + */ +export function resolveRedirectTarget(current, location, guard) { + let next; + try { + next = new URL(location, current).toString(); + } catch { + throw serviceError('redirect_invalid'); + } + if (guard) guard(next); + return next; +} + +/** + * Follows a redirect by hand and checks every hop again. + * + * `fetch` follows a redirect on its own, which would let a hostile server + * answer with a `302` pointing at the network around this machine and walk + * right past the URL guard. Each hop is therefore validated with the same + * rule as the first URL. + */ +async function fetchGuarded(url, init, signal, guard) { + if (guard) guard(url); + let current = url; + for (let hop = 0; hop <= MAX_REDIRECT_HOPS; hop += 1) { + const response = await fetch(current, { ...init, redirect: 'manual', signal }); + if (response.status < 300 || response.status >= 400) return response; + const location = response.headers.get('location'); + try { + await response.body?.cancel(); + } catch { + // the body was already closed + } + if (!location) return response; + if (hop === MAX_REDIRECT_HOPS) throw serviceError('too_many_redirects'); + current = resolveRedirectTarget(current, location, guard); + } + throw serviceError('too_many_redirects'); +} + /** * HTTP helper with retry. The built-in fetch fails with a 10s connect timeout * on this network's proxied first connection, so transient connect failures are * retried instead of being reported as an empty or failed probe. + * + * `guard` is called for the first URL and for every redirect hop, so no + * request can leave the network the caller allowed. */ -async function httpJson(url, init, timeoutMs = PROBE_TIMEOUT_MS, attempts = 3, headersOnly = false) { +async function httpJson(url, init, timeoutMs = PROBE_TIMEOUT_MS, attempts = 3, headersOnly = false, guard = null) { let lastError = null; for (let attempt = 1; attempt <= attempts; attempt += 1) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch(url, { ...init, signal: controller.signal }); + const response = await fetchGuarded(url, init, controller.signal, guard); if (headersOnly) { // The body of an SSE stream never ends, so it must not be read. try { @@ -297,6 +344,14 @@ async function httpJson(url, init, timeoutMs = PROBE_TIMEOUT_MS, attempts = 3, h throw lastError instanceof Error ? lastError : new Error('request_failed'); } +/** + * Exported for the package tests: one request that checks the first URL and + * every redirect hop with the given guard. + */ +export async function guardedRequest(url, init, guard) { + return httpJson(url, init, PROBE_TIMEOUT_MS, 1, false, guard); +} + /** * The MCP request used to test an endpoint: a real `initialize` handshake. * A plain GET is not a valid MCP call, and servers answer it with 405 before @@ -473,6 +528,16 @@ export function assertMetadataUrl(url) { return parsed; } +/** The hop guard for a URL that a remote server handed us. */ +const strictHop = (url) => { + assertMetadataUrl(url); +}; + +/** The hop guard for the MCP endpoint the user configured themselves. */ +const endpointHop = (allowPrivateNetwork) => (url) => { + assertTargetUrl(url, { allowPrivateNetwork }); +}; + /** * Detects what an MCP endpoint demands and resolves the authorization server * metadata via the RFC 9728 protected-resource document. @@ -486,7 +551,14 @@ export async function discoverAuthorization(url, options = {}) { // The endpoint itself is the one the user configured, so a private address // is allowed only when they opted into that. assertTargetUrl(url, { allowPrivateNetwork: options.allowPrivateNetwork === true }); - const probe = await httpJson(url, probeRequest(url, { transport }), PROBE_TIMEOUT_MS, 3, transport === 'sse'); + const probe = await httpJson( + url, + probeRequest(url, { transport }), + PROBE_TIMEOUT_MS, + 3, + transport === 'sse', + endpointHop(options.allowPrivateNetwork === true), + ); if (probe.status !== 401) { // Only a 2xx proves the endpoint accepted an unauthenticated handshake. // 404 / 405 / 5xx say nothing about whether auth is required. @@ -517,7 +589,14 @@ export async function discoverAuthorization(url, options = {}) { reason: bearer ? 'bearer_without_resource_metadata' : 'credential_required_unknown_scheme', }; } - const resourceDoc = await httpJson(assertMetadataUrl(resourceMatch[1]).toString(), { headers: { accept: 'application/json' } }); + const resourceDoc = await httpJson( + assertMetadataUrl(resourceMatch[1]).toString(), + { headers: { accept: 'application/json' } }, + PROBE_TIMEOUT_MS, + 3, + false, + strictHop, + ); const issuers = Array.isArray(resourceDoc.json?.authorization_servers) ? resourceDoc.json.authorization_servers : []; const metadata = await loadAuthorizationServerMetadata(issuers); return { @@ -539,7 +618,14 @@ async function loadAuthorizationServerMetadata(issuers) { // Every URL a remote server handed us is checked strictly: a hostile // server must not be able to point us at the network around this host. assertMetadataUrl(candidate); - const response = await httpJson(candidate, { headers: { accept: 'application/json' } }); + const response = await httpJson( + candidate, + { headers: { accept: 'application/json' } }, + PROBE_TIMEOUT_MS, + 3, + false, + strictHop, + ); if (response.ok && response.json?.token_endpoint) return response.json; } catch { // try the next well-known location @@ -576,11 +662,18 @@ async function registerClient(metadata, { redirectUri, scope, grantTypes }) { token_endpoint_auth_method: pickAuthMethod(metadata), }; if (scope) body.scope = scope; - const response = await httpJson(endpoint, { - method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json' }, - body: JSON.stringify(body), - }); + const response = await httpJson( + endpoint, + { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify(body), + }, + PROBE_TIMEOUT_MS, + 3, + false, + strictHop, + ); if (!response.ok || !response.json?.client_id) { throw serviceError(`dynamic_registration_failed_${response.status}`); } @@ -590,6 +683,10 @@ async function registerClient(metadata, { redirectUri, scope, grantTypes }) { function buildAuthorizeUrl(metadata, { clientId, redirectUri, state, codeChallenge, scope }) { const endpoint = metadata?.authorization_endpoint; if (!endpoint) throw serviceError('authorization_endpoint_missing'); + // The browser is about to be sent here, so the address is checked as well: + // a hostile server must not be able to point the consent page at the + // network around this machine. + assertMetadataUrl(endpoint); const url = new URL(endpoint); url.searchParams.set('response_type', 'code'); url.searchParams.set('client_id', clientId); @@ -619,11 +716,18 @@ async function requestToken(metadata, client, form) { if (client.client_secret && client.auth_method === 'client_secret_basic') { headers.authorization = `Basic ${Buffer.from(`${client.client_id}:${client.client_secret}`).toString('base64')}`; } - const response = await httpJson(metadata.token_endpoint, { - method: 'POST', - headers, - body: form.toString(), - }); + const response = await httpJson( + metadata.token_endpoint, + { + method: 'POST', + headers, + body: form.toString(), + }, + PROBE_TIMEOUT_MS, + 3, + false, + strictHop, + ); if (!response.ok || !response.json?.access_token) { const code = response.json?.error || `http_${response.status}`; throw serviceError(`token_request_failed_${code}`); @@ -635,10 +739,17 @@ async function requestToken(metadata, client, form) { * Verifies a token against the MCP endpoint with a handshake that matches the * declared transport. */ -export async function probeMcpEndpoint(url, token, transport) { +export async function probeMcpEndpoint(url, token, transport, options = {}) { const isSse = transport === 'sse'; try { - const response = await httpJson(url, probeRequest(url, { transport, token }), PROBE_TIMEOUT_MS, 3, isSse); + const response = await httpJson( + url, + probeRequest(url, { transport, token }), + PROBE_TIMEOUT_MS, + 3, + isSse, + endpointHop(options.allowPrivateNetwork === true), + ); return { status: response.status, ok: response.status === 200 || response.status === 204 }; } catch (error) { return { status: 0, ok: false, error: error instanceof Error ? error.message : 'probe_failed' }; @@ -931,7 +1042,11 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { const existing = servers.find((item) => item.name === serverName); const targetUrl = existing?.url || url; if (!targetUrl) throw serviceError('target_url_required'); - assertTargetUrl(targetUrl); + // The endpoint is the one the user configured, so a private address is + // allowed when they opted into that. The URLs the server hands back are + // checked strictly later on. + const { allowPrivateNetwork } = await loadSettings(); + assertTargetUrl(targetUrl, { allowPrivateNetwork }); const transport = existing?.type === 'sse' ? 'sse' : 'streamable-http'; const discovery = await probeDiscovery(serverName, targetUrl, transport); @@ -1123,7 +1238,8 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { awaitingConfirmation.delete(serverName); const { servers: configured } = await listConfiguredServers(); const transport = configured.find((item) => item.name === serverName)?.type; - const probe = grant.url ? await probeMcpEndpoint(grant.url, token.access_token, transport) : null; + const { allowPrivateNetwork } = await loadSettings(); + const probe = grant.url ? await probeMcpEndpoint(grant.url, token.access_token, transport, { allowPrivateNetwork }) : null; log('info', `oauth.grant_applied server=${serverName} probe=${probe?.status ?? 'n/a'}`); return { serverName, @@ -1187,7 +1303,8 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { }; await saveCredential(serverName, next); const patch = await patchMcpAuthorization(serverName, token.access_token, { url }); - const probe = await probeMcpEndpoint(url, token.access_token, server?.type); + const { allowPrivateNetwork } = await loadSettings(); + const probe = await probeMcpEndpoint(url, token.access_token, server?.type, { allowPrivateNetwork }); log('info', `oauth.token_refreshed server=${serverName} probe=${probe.status}`); return { serverName, @@ -1329,7 +1446,8 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { const existing = servers.find((item) => item.name === serverName); const targetUrl = existing?.url || url; if (!targetUrl) throw serviceError('target_url_required'); - assertTargetUrl(targetUrl); + const { allowPrivateNetwork } = await loadSettings(); + assertTargetUrl(targetUrl, { allowPrivateNetwork }); const transport = existing?.type === 'sse' ? 'sse' : 'streamable-http'; const discovery = await probeDiscovery(serverName, targetUrl, transport); const metadata = discovery.metadata; @@ -1359,11 +1477,18 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { if (client.client_secret && authMethod === 'client_secret_basic') { deviceHeaders.authorization = `Basic ${Buffer.from(`${client.client_id}:${client.client_secret}`).toString('base64')}`; } - const response = await httpJson(assertMetadataUrl(metadata.device_authorization_endpoint).toString(), { - method: 'POST', - headers: deviceHeaders, - body: form.toString(), - }); + const response = await httpJson( + assertMetadataUrl(metadata.device_authorization_endpoint).toString(), + { + method: 'POST', + headers: deviceHeaders, + body: form.toString(), + }, + PROBE_TIMEOUT_MS, + 3, + false, + strictHop, + ); if (!response.ok || !response.json?.device_code) { log('warn', `oauth.device_request_failed server=${serverName} status=${response.status} body=${String(response.text).slice(0, 200)}`); throw serviceError(`device_authorization_failed_${response.status}`); @@ -1433,7 +1558,14 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { } let body = null; try { - const response = await httpJson(session.metadata.token_endpoint, { method: 'POST', headers, body: form.toString() }); + const response = await httpJson( + session.metadata.token_endpoint, + { method: 'POST', headers, body: form.toString() }, + PROBE_TIMEOUT_MS, + 3, + false, + strictHop, + ); body = response.json ?? {}; if (response.ok && body.access_token) { deviceSessions.delete(session.serverName); diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs b/plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs index 3e0162fc..e3b5f78b 100644 --- a/plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs @@ -99,7 +99,9 @@ export async function start(context) { // Only the keys of `fields` leave the process, so the reason goes in the // message itself. context.logger.error(`miniapp.request.failed reason=${code}`); - if (!response.headersSent) fail(response, code); + // An unregistered code is an internal detail: the page gets the generic + // one, never the raw reason. + if (!response.headersSent) fail(response, isKnownServiceError(code) ? code : 'internal_error'); else response.end(); }); }); diff --git a/plugins/microbiosis/mcp-oauth-console/tests/entry-loads.test.mjs b/plugins/microbiosis/mcp-oauth-console/tests/entry-loads.test.mjs index 399bd370..18976b10 100644 --- a/plugins/microbiosis/mcp-oauth-console/tests/entry-loads.test.mjs +++ b/plugins/microbiosis/mcp-oauth-console/tests/entry-loads.test.mjs @@ -27,3 +27,20 @@ test('every code the service throws is registered in the catalog', async () => { const unknown = [...new Set(used)].filter((code) => !isKnownServiceError(code)); assert.deepEqual(unknown, [], `unregistered error codes: ${unknown.join(', ')}`); }); + +test('no node file throws a raw code that would reach the page', async () => { + const { readFile, readdir } = await import('node:fs/promises'); + const dir = new URL('../miniapp/node/', import.meta.url); + const names = await readdir(dir); + const raw = []; + for (const name of names) { + if (!name.endsWith('.mjs')) continue; + const source = await readFile(new URL(name, dir), 'utf8'); + // A raw `throw new Error('some_code')` bypasses the catalog, so the code + // would be handed to the page as-is. + for (const match of source.matchAll(/throw new Error\(\s*'([a-z0-9_]+)'/g)) { + raw.push(`${name}: ${match[1]}`); + } + } + assert.deepEqual(raw, [], `raw error codes must go through the catalog: ${raw.join(', ')}`); +}); diff --git a/plugins/microbiosis/mcp-oauth-console/tests/redirect-guard.test.mjs b/plugins/microbiosis/mcp-oauth-console/tests/redirect-guard.test.mjs new file mode 100644 index 00000000..6114ce51 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/tests/redirect-guard.test.mjs @@ -0,0 +1,118 @@ +// A redirect must not be a way around the URL guard. `fetch` follows a +// redirect on its own, so every hop is checked again before it is requested. +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { test } from 'node:test'; + +import { + assertMetadataUrl, + assertTargetUrl, + guardedRequest, + resolveRedirectTarget, +} from '../miniapp/node/oauth-service.mjs'; + +test('a redirect to a private address is refused', () => { + const hops = [ + ['http://169.254.169.254/latest/meta-data', 'instance metadata'], + ['http://127.0.0.1:8080/mcp', 'loopback'], + ['http://10.0.0.5/mcp', 'rfc1918'], + ['http://[::1]:8080/mcp', 'ipv6 loopback'], + ]; + for (const [location, label] of hops) { + assert.throws( + () => resolveRedirectTarget('https://example.com/mcp', location, assertMetadataUrl), + undefined, + `expected a refusal for ${label}`, + ); + } +}); + +test('a redirect to a public address is accepted', () => { + assert.equal( + resolveRedirectTarget('https://example.com/mcp', 'https://example.net/mcp', assertMetadataUrl), + 'https://example.net/mcp', + ); +}); + +test('a relative redirect stays on the same host', () => { + assert.equal( + resolveRedirectTarget('https://example.com/mcp', '/other', assertMetadataUrl), + 'https://example.com/other', + ); +}); + +/** + * Runs the guarded request and returns the error the guard raised. + * + * A port that was taken between the bind and the connect shows up as a + * connection error, which is not what this test is about, so it is retried + * instead of being reported as a failure of the guard. + */ +async function requestUntilRefused(url, guard) { + let lastError = null; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + await guardedRequest(url, {}, guard); + return null; + } catch (error) { + lastError = error; + if (error instanceof Error && /blocked|target_url/.test(error.message)) return error; + await new Promise((resolve) => setTimeout(resolve, 60)); + } + } + throw lastError; +} + +test('the redirected host is never requested when the guard refuses the hop', async () => { + let hits = 0; + const secret = createServer((_request, response) => { + hits += 1; + response.writeHead(200).end('secret'); + }); + await new Promise((resolve) => secret.listen(0, '127.0.0.1', resolve)); + const secretPort = secret.address().port; + + const redirector = createServer((_request, response) => { + response.writeHead(302, { location: `http://127.0.0.1:${secretPort}/secret` }).end(); + }); + await new Promise((resolve) => redirector.listen(0, '127.0.0.1', resolve)); + const redirectorPort = redirector.address().port; + + try { + const allowed = `http://127.0.0.1:${redirectorPort}/`; + // The guard lets the first URL through and refuses anything else. + const guard = (url) => { + if (url !== allowed) throw new Error('blocked'); + }; + const error = await requestUntilRefused(allowed, guard); + assert.ok(error, 'the refused hop must stop the request'); + assert.equal(hits, 0, 'the refused hop must never be requested'); + } finally { + secret.closeAllConnections?.(); + redirector.closeAllConnections?.(); + await new Promise((resolve) => secret.close(resolve)); + await new Promise((resolve) => redirector.close(resolve)); + } +}); + +test('the endpoint guard refuses a private hop even when the first URL is allowed', async () => { + const redirector = createServer((_request, response) => { + response.writeHead(302, { location: 'http://169.254.169.254/latest/meta-data' }).end(); + }); + await new Promise((resolve) => redirector.listen(0, '127.0.0.1', resolve)); + const port = redirector.address().port; + const first = `http://127.0.0.1:${port}/`; + try { + // The first URL is the loopback the user pointed at; the hop is not. + const guard = (url) => { + if (url === first) return; + assertTargetUrl(url, { allowPrivateNetwork: false }); + }; + const error = await requestUntilRefused(first, guard); + assert.ok(error, 'a private hop must stop the request'); + assert.match(error.message, /target_url/); + } finally { + redirector.closeAllConnections?.(); + await new Promise((resolve) => redirector.close(resolve)); + } +}); From 10c5af9dac75bf9a837f86f7d3d268caa9ccfc68 Mon Sep 17 00:00:00 2001 From: Microbiosis <86361982+Microbiosis@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:40:13 +0800 Subject: [PATCH 5/5] Do not hand a credential to a redirect target The manual redirect loop resent the original request on every hop, so a 302 to another origin carried the Authorization header and the request body - refresh_token, client_secret, code or code_verifier - to a host that never received it. fetch drops the Authorization header on a cross-origin redirect; the loop has to do the same. - a redirect to another origin is followed without the Authorization header and without cookies - a 301, 302 or 303 turns the POST into a GET without a body, like a browser - a request whose body holds a secret is never redirected at all: the token, registration and device endpoints are not expected to redirect, so the request is refused instead Also stop the search for mcp.json at the MiniMax data directory, so an unrelated mcp.json in a parent folder cannot be picked up. httpJson now takes an options object instead of six positional arguments. --- .../microbiosis/mcp-oauth-console/README.md | 8 +- .../mcp-oauth-console/README.zh-CN.md | 8 +- .../mcp-oauth-console/miniapp/node/errors.mjs | 1 + .../miniapp/node/oauth-service.mjs | 116 ++++++++++------ .../tests/config-path.test.mjs | 33 +++++ .../tests/redirect-guard.test.mjs | 125 ++++++++++++++---- 6 files changed, 223 insertions(+), 68 deletions(-) create mode 100644 plugins/microbiosis/mcp-oauth-console/tests/config-path.test.mjs diff --git a/plugins/microbiosis/mcp-oauth-console/README.md b/plugins/microbiosis/mcp-oauth-console/README.md index b763a942..b383e05b 100644 --- a/plugins/microbiosis/mcp-oauth-console/README.md +++ b/plugins/microbiosis/mcp-oauth-console/README.md @@ -79,7 +79,7 @@ When a server does not declare OAuth the browser flow is not offered: there is n 1. the Host's own data-dir chain — it walks up from `context.dataDir` (the plugin's data directory lives inside the MiniMax data directory) and takes the first `mcp.json` it finds; 2. the documented default location, `~/.minimax/mcp.json`. -The process working directory and the Node executable's directory are deliberately **not** searched, so an unrelated `mcp.json` next to them can never win. The layout is still unspecified: on a Host that keeps the configuration somewhere else, the plugin finds no servers and shows an empty list. +The process working directory and the Node executable's directory are deliberately **not** searched, so an unrelated `mcp.json` next to them can never win. The walk **stops at the MiniMax data directory** and never reaches the home directory, so a stray `mcp.json` there is not picked up either. The layout is still unspecified: on a Host that keeps the configuration somewhere else, the plugin finds no servers and shows an empty list. A credential directory left next to `mcp.json` by an earlier version of this plugin is read once and imported into `context.dataDir`. It is never written to again. @@ -115,6 +115,12 @@ Every URL is validated before the process requests it: Redirects are followed by hand, and **every hop is checked again with the same rule** — a `302` must not be a way around the guard. At most five hops are followed. +A redirect also must not hand the credential to a host that did not receive it: + +- a redirect to another origin is followed **without** the `Authorization` header and without cookies, the way `fetch` itself drops them; +- a `301`, `302` or `303` turns the `POST` into a `GET` without a body, the way a browser does; +- a request whose body holds a secret — a token request, a client registration, a device code — is **never redirected**. Those endpoints are not expected to redirect, so the request is refused instead. + Two tiers exist. A URL that a **remote server** handed back — a metadata document, a token endpoint, a device endpoint — is always checked strictly and can never reach a private address. A URL the **user** configured as their own MCP endpoint may point at a private address, but only when the "Allow private addresses" switch is on. ### Spawned processes diff --git a/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md b/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md index 06736231..cae8cea3 100644 --- a/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md +++ b/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md @@ -79,7 +79,7 @@ 1. 宿主自己的数据目录链——从 `context.dataDir` 向上找(插件的数据目录就在 MiniMax 数据目录里面),取遇到的第一个 `mcp.json`; 2. 文档写明的默认位置 `~/.minimax/mcp.json`。 -进程工作目录和 Node 可执行文件所在目录**刻意不查**,所以旁边恰好存在的无关 `mcp.json` 不可能抢到。布局仍然是未公开的:如果宿主把配置放在别处,插件会找不到服务器并显示空列表。 +进程工作目录和 Node 可执行文件所在目录**刻意不查**,所以旁边恰好存在的无关 `mcp.json` 不可能抢到。向上查找**到 MiniMax 数据目录为止**,也不会再伸到用户主目录,所以主目录里一个落单的 `mcp.json` 同样不会被认领。布局仍然是未公开的:如果宿主把配置放在别处,插件会找不到服务器并显示空列表。 旧版本插件遗留在 `mcp.json` 旁边的凭据目录会被读取一次并导入 `context.dataDir`,之后不再写入。 @@ -115,6 +115,12 @@ 重定向是**手动跟随**的,**每一跳都用同一条规则重新检查**——`302` 不能成为绕过守卫的路径。最多跟随 5 跳。 +重定向也不能把凭据交给一个本没收到它的主机: + +- 跨源重定向**不带** `Authorization` 头、也不带 cookie(`fetch` 自带的行为); +- `301`、`302`、`303` 会把 `POST` 变成无请求体的 `GET`(浏览器行为); +- **请求体里装着密钥的请求(令牌请求、动态注册、设备码)一律不跟随重定向** —— 那些接口本来就不该重定向,所以直接拒绝该请求。 + 这里分两层:**远程服务器**回给你的 URL(元数据文档、令牌端点、设备端点)一律按最严格标准检查,永远够不到内网地址;**你自己配置**的 MCP 端点可以指向内网地址,但前提是打开了「允许内网地址」开关。 ### 启动的子进程 diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/node/errors.mjs b/plugins/microbiosis/mcp-oauth-console/miniapp/node/errors.mjs index 3fe3cc65..79a69492 100644 --- a/plugins/microbiosis/mcp-oauth-console/miniapp/node/errors.mjs +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/node/errors.mjs @@ -26,6 +26,7 @@ const ERROR_CATALOG = { target_url_contains_credentials: { status: 400, message: 'MCP 地址里不能带用户名或密码' }, target_url_blocked: { status: 400, message: '该地址属于实例元数据或链路本地网段,已拒绝访问' }, redirect_invalid: { status: 400, message: '服务端返回的重定向地址无法解析,已停止请求' }, + redirect_not_allowed: { status: 502, message: '该接口返回了重定向,出于安全考虑已停止请求' }, too_many_redirects: { status: 502, message: '重定向次数过多,已停止请求' }, config_not_parseable: { status: 500, message: 'mcp.json 的内容无法解析,已放弃写入' }, invalid_server_name: { status: 400, message: '服务器名称含有非法字符(只允许字母、数字、点、下划线和连字符)' }, diff --git a/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs b/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs index 4d093e32..c0dc10ef 100644 --- a/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs @@ -22,6 +22,8 @@ import { serviceError } from './errors.mjs'; const CLIENT_NAME = 'MCP OAuth Console'; const CALLBACK_PATH = '/oauth/callback'; +/** The name of the Host's data directory. The search for `mcp.json` stops there. */ +const MINIMAX_DIR_NAME = '.minimax'; const REFRESH_WINDOW_MS = 30 * 60 * 1000; /** How long a successful detection stays valid, and how long a failed one is * remembered before a retry is allowed (a flaky network must not pin a wrong @@ -161,19 +163,26 @@ function findMemberStringSpan(text, key, from, to) { * The process working directory and the Node executable's directory are * deliberately not searched, so an unrelated `mcp.json` there can never win. * + * The search stops at the MiniMax data directory: the Host's configuration + * lives there, and an `mcp.json` one level above it belongs to something + * else. The user's home directory is the outer bound for the same reason. + * * The result is a candidate only. Before anything is written it is realpath'ed * and shown in the page, and the write is bound to that exact path. */ export function resolveMcpConfigPath(dataDir) { + const home = homedir(); let dir = resolve(dataDir); for (let depth = 0; depth < 10; depth += 1) { const candidate = join(dir, 'mcp.json'); if (existsSync(candidate)) return candidate; + // The MiniMax data directory is the boundary of the search. + if (basename(dir) === MINIMAX_DIR_NAME) break; const parent = dirname(dir); - if (parent === dir) break; + if (parent === dir || parent === home) break; dir = parent; } - return join(homedir(), '.minimax', 'mcp.json'); + return join(home, MINIMAX_DIR_NAME, 'mcp.json'); } /** The canonical path of the configuration file, with symlinks resolved. */ @@ -275,6 +284,20 @@ export function resolveRedirectTarget(current, location, guard) { return next; } +/** + * Headers that must never travel to a host that did not receive them. + */ +const CREDENTIAL_HEADERS = ['authorization', 'cookie', 'proxy-authorization']; + +/** Returns the header set without the credential headers. */ +function withoutCredentialHeaders(headers) { + const out = {}; + for (const [name, value] of Object.entries(headers)) { + if (!CREDENTIAL_HEADERS.includes(name.toLowerCase())) out[name] = value; + } + return out; +} + /** * Follows a redirect by hand and checks every hop again. * @@ -282,12 +305,19 @@ export function resolveRedirectTarget(current, location, guard) { * answer with a `302` pointing at the network around this machine and walk * right past the URL guard. Each hop is therefore validated with the same * rule as the first URL. + * + * A redirect must also not carry the credential to a host that did not + * receive it: `fetch` drops the `Authorization` header on a cross-origin + * redirect, and the manual loop has to do the same. A request whose *body* + * holds a secret - a token request, a client registration, a device code - + * is not followed at all: those endpoints are not expected to redirect. */ -async function fetchGuarded(url, init, signal, guard) { +async function fetchGuarded(url, init, signal, guard, sensitive = false) { if (guard) guard(url); let current = url; + let currentInit = { ...init, redirect: 'manual' }; for (let hop = 0; hop <= MAX_REDIRECT_HOPS; hop += 1) { - const response = await fetch(current, { ...init, redirect: 'manual', signal }); + const response = await fetch(current, { ...currentInit, signal }); if (response.status < 300 || response.status >= 400) return response; const location = response.headers.get('location'); try { @@ -296,8 +326,22 @@ async function fetchGuarded(url, init, signal, guard) { // the body was already closed } if (!location) return response; + if (sensitive) throw serviceError('redirect_not_allowed'); if (hop === MAX_REDIRECT_HOPS) throw serviceError('too_many_redirects'); - current = resolveRedirectTarget(current, location, guard); + const next = resolveRedirectTarget(current, location, guard); + // A redirect to another origin must not carry the credential along. + if (currentInit.headers && new URL(next).origin !== new URL(current).origin) { + currentInit = { ...currentInit, headers: withoutCredentialHeaders(currentInit.headers) }; + } + // 301, 302 and 303 turn a POST into a GET without a body, like a browser. + if ( + String(currentInit.method ?? 'GET').toUpperCase() === 'POST' && + (response.status === 301 || response.status === 302 || response.status === 303) + ) { + currentInit = { ...currentInit, method: 'GET' }; + delete currentInit.body; + } + current = next; } throw serviceError('too_many_redirects'); } @@ -308,15 +352,23 @@ async function fetchGuarded(url, init, signal, guard) { * retried instead of being reported as an empty or failed probe. * * `guard` is called for the first URL and for every redirect hop, so no - * request can leave the network the caller allowed. + * request can leave the network the caller allowed. `sensitive` marks a + * request whose body holds a credential: it is never redirected. */ -async function httpJson(url, init, timeoutMs = PROBE_TIMEOUT_MS, attempts = 3, headersOnly = false, guard = null) { +async function httpJson(url, init, options = {}) { + const { + timeoutMs = PROBE_TIMEOUT_MS, + attempts = 3, + headersOnly = false, + guard = null, + sensitive = false, + } = options; let lastError = null; for (let attempt = 1; attempt <= attempts; attempt += 1) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetchGuarded(url, init, controller.signal, guard); + const response = await fetchGuarded(url, init, controller.signal, guard, sensitive); if (headersOnly) { // The body of an SSE stream never ends, so it must not be read. try { @@ -348,8 +400,8 @@ async function httpJson(url, init, timeoutMs = PROBE_TIMEOUT_MS, attempts = 3, h * Exported for the package tests: one request that checks the first URL and * every redirect hop with the given guard. */ -export async function guardedRequest(url, init, guard) { - return httpJson(url, init, PROBE_TIMEOUT_MS, 1, false, guard); +export async function guardedRequest(url, init, options = {}) { + return httpJson(url, init, { attempts: 1, ...options }); } /** @@ -554,10 +606,7 @@ export async function discoverAuthorization(url, options = {}) { const probe = await httpJson( url, probeRequest(url, { transport }), - PROBE_TIMEOUT_MS, - 3, - transport === 'sse', - endpointHop(options.allowPrivateNetwork === true), + { timeoutMs: PROBE_TIMEOUT_MS, attempts: 3, headersOnly: transport === 'sse', guard: endpointHop(options.allowPrivateNetwork === true) }, ); if (probe.status !== 401) { // Only a 2xx proves the endpoint accepted an unauthenticated handshake. @@ -592,10 +641,7 @@ export async function discoverAuthorization(url, options = {}) { const resourceDoc = await httpJson( assertMetadataUrl(resourceMatch[1]).toString(), { headers: { accept: 'application/json' } }, - PROBE_TIMEOUT_MS, - 3, - false, - strictHop, + { guard: strictHop }, ); const issuers = Array.isArray(resourceDoc.json?.authorization_servers) ? resourceDoc.json.authorization_servers : []; const metadata = await loadAuthorizationServerMetadata(issuers); @@ -621,10 +667,7 @@ async function loadAuthorizationServerMetadata(issuers) { const response = await httpJson( candidate, { headers: { accept: 'application/json' } }, - PROBE_TIMEOUT_MS, - 3, - false, - strictHop, + { guard: strictHop }, ); if (response.ok && response.json?.token_endpoint) return response.json; } catch { @@ -669,10 +712,8 @@ async function registerClient(metadata, { redirectUri, scope, grantTypes }) { headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(body), }, - PROBE_TIMEOUT_MS, - 3, - false, - strictHop, + // The body carries the client secret, so this request is never redirected. + { guard: strictHop, sensitive: true }, ); if (!response.ok || !response.json?.client_id) { throw serviceError(`dynamic_registration_failed_${response.status}`); @@ -723,10 +764,8 @@ async function requestToken(metadata, client, form) { headers, body: form.toString(), }, - PROBE_TIMEOUT_MS, - 3, - false, - strictHop, + // The body carries the refresh token, the client secret or the code. + { guard: strictHop, sensitive: true }, ); if (!response.ok || !response.json?.access_token) { const code = response.json?.error || `http_${response.status}`; @@ -745,10 +784,7 @@ export async function probeMcpEndpoint(url, token, transport, options = {}) { const response = await httpJson( url, probeRequest(url, { transport, token }), - PROBE_TIMEOUT_MS, - 3, - isSse, - endpointHop(options.allowPrivateNetwork === true), + { timeoutMs: PROBE_TIMEOUT_MS, attempts: 3, headersOnly: isSse, guard: endpointHop(options.allowPrivateNetwork === true) }, ); return { status: response.status, ok: response.status === 200 || response.status === 204 }; } catch (error) { @@ -1484,10 +1520,8 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { headers: deviceHeaders, body: form.toString(), }, - PROBE_TIMEOUT_MS, - 3, - false, - strictHop, + // The body carries the client secret. + { guard: strictHop, sensitive: true }, ); if (!response.ok || !response.json?.device_code) { log('warn', `oauth.device_request_failed server=${serverName} status=${response.status} body=${String(response.text).slice(0, 200)}`); @@ -1561,10 +1595,8 @@ export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { const response = await httpJson( session.metadata.token_endpoint, { method: 'POST', headers, body: form.toString() }, - PROBE_TIMEOUT_MS, - 3, - false, - strictHop, + // The body carries the client secret. + { guard: strictHop, sensitive: true }, ); body = response.json ?? {}; if (response.ok && body.access_token) { diff --git a/plugins/microbiosis/mcp-oauth-console/tests/config-path.test.mjs b/plugins/microbiosis/mcp-oauth-console/tests/config-path.test.mjs new file mode 100644 index 00000000..fd4d4f84 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/tests/config-path.test.mjs @@ -0,0 +1,33 @@ +// The search for `mcp.json` stops at the MiniMax data directory: an +// `mcp.json` one level above it belongs to something else. +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { resolveMcpConfigPath } from '../miniapp/node/oauth-service.mjs'; + +async function makeLayout() { + const root = await mkdtemp(join(tmpdir(), 'mcp-oauth-console-config-')); + const minimax = join(root, '.minimax'); + const dataDir = join(minimax, 'v2', 'plugin-data', 'liveboards', 'mcp-oauth-console'); + await mkdir(dataDir, { recursive: true }); + // An unrelated configuration one level above the MiniMax data directory. + await writeFile(join(root, 'mcp.json'), '{}', 'utf8'); + return { root, minimax, dataDir }; +} + +test('the configuration inside the MiniMax data directory wins', async () => { + const { minimax, dataDir } = await makeLayout(); + const expected = join(minimax, 'mcp.json'); + await writeFile(expected, '{}', 'utf8'); + assert.equal(resolveMcpConfigPath(dataDir), expected); +}); + +test('an unrelated configuration above the MiniMax data directory is not used', async () => { + const { root, dataDir } = await makeLayout(); + const resolved = resolveMcpConfigPath(dataDir); + assert.notEqual(resolved, join(root, 'mcp.json'), 'the unrelated file must not be used'); + assert.equal(resolved, join(homedir(), '.minimax', 'mcp.json')); +}); diff --git a/plugins/microbiosis/mcp-oauth-console/tests/redirect-guard.test.mjs b/plugins/microbiosis/mcp-oauth-console/tests/redirect-guard.test.mjs index 6114ce51..796937f7 100644 --- a/plugins/microbiosis/mcp-oauth-console/tests/redirect-guard.test.mjs +++ b/plugins/microbiosis/mcp-oauth-console/tests/redirect-guard.test.mjs @@ -1,5 +1,6 @@ -// A redirect must not be a way around the URL guard. `fetch` follows a -// redirect on its own, so every hop is checked again before it is requested. +// A redirect must not be a way around the URL guard, and it must not carry a +// credential to a host that did not receive it. `fetch` follows a redirect on +// its own, so every hop is checked again before it is requested. import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { test } from 'node:test'; @@ -11,6 +12,28 @@ import { resolveRedirectTarget, } from '../miniapp/node/oauth-service.mjs'; +/** + * Runs the guarded request and returns the error the guard raised. + * + * A port that was taken between the bind and the connect shows up as a + * connection error, which is not what this test is about, so it is retried + * instead of being reported as a failure of the guard. + */ +async function requestUntilRefused(url, guard) { + let lastError = null; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + await guardedRequest(url, {}, { guard }); + return null; + } catch (error) { + lastError = error; + if (error instanceof Error && /blocked|target_url/.test(error.message)) return error; + await new Promise((resolve) => setTimeout(resolve, 60)); + } + } + throw lastError; +} + test('a redirect to a private address is refused', () => { const hops = [ ['http://169.254.169.254/latest/meta-data', 'instance metadata'], @@ -41,28 +64,6 @@ test('a relative redirect stays on the same host', () => { ); }); -/** - * Runs the guarded request and returns the error the guard raised. - * - * A port that was taken between the bind and the connect shows up as a - * connection error, which is not what this test is about, so it is retried - * instead of being reported as a failure of the guard. - */ -async function requestUntilRefused(url, guard) { - let lastError = null; - for (let attempt = 1; attempt <= 3; attempt += 1) { - try { - await guardedRequest(url, {}, guard); - return null; - } catch (error) { - lastError = error; - if (error instanceof Error && /blocked|target_url/.test(error.message)) return error; - await new Promise((resolve) => setTimeout(resolve, 60)); - } - } - throw lastError; -} - test('the redirected host is never requested when the guard refuses the hop', async () => { let hits = 0; const secret = createServer((_request, response) => { @@ -116,3 +117,79 @@ test('the endpoint guard refuses a private hop even when the first URL is allowe await new Promise((resolve) => redirector.close(resolve)); } }); + +test('a cross-origin redirect does not carry the Authorization header', async () => { + let seen = null; + const other = createServer((request, response) => { + seen = { authorization: request.headers.authorization ?? null, cookie: request.headers.cookie ?? null }; + response.writeHead(200).end('ok'); + }); + await new Promise((resolve) => other.listen(0, '127.0.0.1', resolve)); + const otherPort = other.address().port; + + const redirector = createServer((_request, response) => { + response.writeHead(302, { location: `http://127.0.0.1:${otherPort}/target` }).end(); + }); + await new Promise((resolve) => redirector.listen(0, '127.0.0.1', resolve)); + const redirectorPort = redirector.address().port; + + try { + const first = `http://127.0.0.1:${redirectorPort}/`; + await guardedRequest( + first, + { + method: 'POST', + headers: { authorization: 'Bearer secret-token', cookie: 'session=abc', 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }), + }, + { guard: (url) => { if (url !== first && !url.includes(`:${otherPort}`)) throw new Error('blocked'); } }, + ); + assert.ok(seen, 'the redirect target must have been reached'); + assert.equal(seen.authorization, null, 'the token must not travel to another origin'); + assert.equal(seen.cookie, null, 'the cookie must not travel to another origin'); + } finally { + other.closeAllConnections?.(); + redirector.closeAllConnections?.(); + await new Promise((resolve) => other.close(resolve)); + await new Promise((resolve) => redirector.close(resolve)); + } +}); + +test('a request whose body holds a secret is never redirected', async () => { + let hits = 0; + const other = createServer((request, response) => { + hits += 1; + response.writeHead(200).end(JSON.stringify({ leaked: request.body ?? null })); + }); + await new Promise((resolve) => other.listen(0, '127.0.0.1', resolve)); + const otherPort = other.address().port; + + const redirector = createServer((_request, response) => { + response.writeHead(302, { location: `http://127.0.0.1:${otherPort}/token` }).end(); + }); + await new Promise((resolve) => redirector.listen(0, '127.0.0.1', resolve)); + const redirectorPort = redirector.address().port; + + try { + const first = `http://127.0.0.1:${redirectorPort}/token`; + await assert.rejects( + () => + guardedRequest( + first, + { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: 'grant_type=refresh_token&refresh_token=the-secret', + }, + { guard: () => {}, sensitive: true }, + ), + /redirect_not_allowed/, + ); + assert.equal(hits, 0, 'the body must never be re-sent to the redirect target'); + } finally { + other.closeAllConnections?.(); + redirector.closeAllConnections?.(); + await new Promise((resolve) => other.close(resolve)); + await new Promise((resolve) => redirector.close(resolve)); + } +});