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..7c72ffd5 --- /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..b383e05b --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/README.md @@ -0,0 +1,191 @@ +# MCP OAuth Console + +[简体中文](README.zh-CN.md) | English + +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` + +## 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 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. This is a manual action and always runs, regardless of the automatic-refresh setting | + +## 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. + +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: + +- `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. 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 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. + +### 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. 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. + +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 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. + +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. + +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 + +| Command | When | +|---|---| +| `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. If the helper cannot be started the page says so and shows the link to copy instead. + +### 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. + +### 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. + +### 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 + +| | | +|---|---| +| 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, 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. + +## 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 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. +- 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 + +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..cae8cea3 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/README.zh-CN.md @@ -0,0 +1,191 @@ +# 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、却并没有配令牌。分开显示,两者都不会互相遮蔽。 + +| 操作 | 行为 | +|---|---| +| 检测 | 按服务端声明的传输方式重新探测,结果显示在**该服务器自己那一行** | +| 授权登录 | 为该服务器完成一次 OAuth 登录,然后弹出确认框,明确告诉你将改动哪个文件的哪个字段 | +| 确认写入 | 改写 `mcp.json` 里 `mcpServers..headers.Authorization`,先备份,再用真实的 MCP 握手验活 | +| 刷新令牌 | 用已存的 refresh_token 换新 access_token 并写回 | +| 刷新即将过期 | 刷新所有距过期不足 30 分钟的令牌。这是手动操作,**不受自动续期开关影响**,一定会执行 | + +## 登录方式 + +页面提供两种方式,并默认推荐第一种。 + +**设备码(推荐)**。服务端向授权服务器申请一串短设备码,页面把它和验证页链接一起显示出来;你在授权页确认后,服务端轮询令牌端点直到拿到授权。**全程没有跳转**,因此不依赖浏览器能不能回到本 App。 + +**授权码 + PKCE**。浏览器打开同意页,授权服务器再跳回本 App 的回环端口。**从 MiniMax Code 3.1.2 起,宿主会对该端口的每个请求做鉴权**,于是回调页会返回 `401`、你的处理器根本不会被执行;但地址栏里仍然带着授权码,把整段 URL 复制回页面即可完成——回调页本身并不需要打开。 + +两种方式都要求 `state` 精确匹配。而且**登录成功并不会立刻写文件**:页面先给出确认,只有确认之后才落盘。 + +## 设置 + +设置面板里有两个开关,以及写入时会碰到的那个文件路径。 + +| 设置 | 默认 | 作用 | +|---|---|---| +| 到期前自动续期 | 关 | Mini App 运行期间,落在 30 分钟窗口内的令牌会被后台刷新。默认关闭,插件不会自作主张地去联系授权服务器。 | +| 允许内网地址 | 关 | 允许**你自己配置的 MCP 端点**使用回环地址、RFC 1918、运营商级 NAT、链路本地与唯一本地地址。这个开关**不适用于**远程服务器返回的 URL——那些一律按最严格的标准检查。 | + +面板里显示的配置路径就是写入真正会碰到的那个文件。它只解析一次,符号链接会被解析成真实路径;如果写入前路径变了,写入会被拒绝。 + +## 探测是怎么做的 + +探针使用服务端在配置里声明的传输方式: + +- `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 访问它。本插件只找两个地方: + +1. 宿主自己的数据目录链——从 `context.dataDir` 向上找(插件的数据目录就在 MiniMax 数据目录里面),取遇到的第一个 `mcp.json`; +2. 文档写明的默认位置 `~/.minimax/mcp.json`。 + +进程工作目录和 Node 可执行文件所在目录**刻意不查**,所以旁边恰好存在的无关 `mcp.json` 不可能抢到。向上查找**到 MiniMax 数据目录为止**,也不会再伸到用户主目录,所以主目录里一个落单的 `mcp.json` 同样不会被认领。布局仍然是未公开的:如果宿主把配置放在别处,插件会找不到服务器并显示空列表。 + +旧版本插件遗留在 `mcp.json` 旁边的凭据目录会被读取一次并导入 `context.dataDir`,之后不再写入。 + +### 写入的文件 + +| 文件 | 内容 | +|---|---| +| `mcp.json` | 只改某个服务器条目的 `Authorization` 头。改写是手术式的:文件其余部分逐字节不变。 | +| `/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。原始文件权限会被带过去(临时文件默认是默认权限)。写入被串行化,两次写入不会交错。写入失败时临时文件会被删除,原内容保持不变。**备份失败会中止写入**——备份是唯一的退路,所以它先于文件被写入,备份失败就什么都不改。 + +读取、改写、写入全程在同一把锁内完成,并且锁内会重新读一遍文件,因此并发编辑不会弄丢条目。 + +### 出网主机 + +插件只访问两类主机: + +1. MCP 配置里列出的 MCP 端点——用于探测和写入后的验活握手。 +2. 这些服务器的 OAuth 端点——取自它们的元数据:授权端点、令牌端点、设备授权端点、客户端注册端点、受保护资源文档,以及 `.well-known` 的授权服务器元数据。 + +除此之外不访问任何主机。没有埋点、没有分析服务、没有第三方服务。 + +请求前会校验每一个 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` 同样被拒绝。 + +重定向是**手动跟随**的,**每一跳都用同一条规则重新检查**——`302` 不能成为绕过守卫的路径。最多跟随 5 跳。 + +重定向也不能把凭据交给一个本没收到它的主机: + +- 跨源重定向**不带** `Authorization` 头、也不带 cookie(`fetch` 自带的行为); +- `301`、`302`、`303` 会把 `POST` 变成无请求体的 `GET`(浏览器行为); +- **请求体里装着密钥的请求(令牌请求、动态注册、设备码)一律不跟随重定向** —— 那些接口本来就不该重定向,所以直接拒绝该请求。 + +这里分两层:**远程服务器**回给你的 URL(元数据文档、令牌端点、设备端点)一律按最严格标准检查,永远够不到内网地址;**你自己配置**的 MCP 端点可以指向内网地址,但前提是打开了「允许内网地址」开关。 + +### 启动的子进程 + +| 命令 | 时机 | +|---|---| +| `explorer.exe `(Windows)、`open `(macOS)、`xdg-open `(Linux) | 每次授权一次,用于在默认浏览器打开同意页 | + +参数以 argv 数组传入,绝不经过 shell。URL 是依据服务端自己的元数据拼出的授权请求。启动器起不来时,页面会明说,并改为把链接给你复制。 + +### 状态存放位置 + +所有持久状态都放在 `context.dataDir`——宿主为这个插件创建的私有目录。除了上面列出的 MCP 配置,不会写到它以外的地方。 + +### Agent 回合 + +没有。本包没有声明 `agentAccess`,因此从不让 Agent 跑回合、读文件或调工具;一切都在 Node 进程内或授权服务器自己的页面里完成。 + +### 读取的会话 + +没有。本包没有声明 `session.read`,因此看不到任何会话、回合或回复。 + +### 调用的工具 + +没有。本包没有声明 `toolAccess`,因此从不调用 MCP 或 Connector 工具。 + +### 麦克风 + +没有。本包没有声明 `deviceAccess`,因此从不录音。 + +### 凭据 + +令牌本体始终留在 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`) | +| 已验证流程 | 动态客户端注册、设备码流程(RFC 8628)、授权码 + PKCE(S256)、授权码换令牌、refresh_token 换令牌、写入后的 MCP `initialize` 握手 | + +已针对 Hugging Face 授权服务器与 Hugging Face Inference Endpoints MCP 服务器端到端验证。 + +## 已知限制 + +- 只能管理已经在 MCP 配置里的服务器,或通过页面新增的服务器;不会自己发现服务器。 +- 需要凭证但没声明 OAuth 的服务器无法从页面登录,只能手工配置请求头。 +- 授权码方式的回调地址要手工复制回来(宿主对回环端口有鉴权),除非授权服务器不提供设备码流程,否则优先用设备码。 +- 探测只验证握手。服务端可能接受未认证的 `initialize`,却在调用工具时才要令牌。 +- 自动续期只在你打开开关后才生效,而且只在 Mini App 运行期间工作。App 停掉期间过期的令牌,会在下一次启动时补刷。 + +## 开发 + +本包自带单元测试,在本目录下运行: + +```bash +node --test "tests/**/*.test.mjs" +``` + +覆盖 URL 守卫、凭据路径的目录约束、对 `mcp.json` 的手术式改写,以及一项静态检查:服务可能抛出的每一个错误码都必须已在目录中登记。 + +## 许可证 + +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 00000000..299a26cb Binary files /dev/null and b/plugins/microbiosis/mcp-oauth-console/icon.png differ 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..bba0737b --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/client/app.js @@ -0,0 +1,1020 @@ +// 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'), + 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'), +}; + +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'; + + 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() { + 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); + 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 }; +} + +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: '', + 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: () => { + 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 = ['读取授权服务器元数据', '动态注册客户端', '在浏览器中完成授权', '回填回调地址', '确认写入 mcp.json']; + const reached = draft.session ? 3 : 0; + 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) { + body.append( + element( + 'div', + 'notice', + '浏览器会跳回本机端口,但宿主对回环端口有鉴权,那个页面会显示 401。地址栏里仍然带着授权码,把它复制回来即可。', + ), + ); + body.append(serverFields(draft)); + return; + } + + const notice = element('div', 'notice'); + 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'; + 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; + }); +} + +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; + } +} + +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('写入文件', view.data?.configPath ?? '—'); + 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', + '这会修改上面列出的文件里该服务器的 Authorization 头,并在 dataDir 下写入凭据文件。写入前会先备份(备份失败则不写)。', + ), + ); + }, + 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, + confirmToken: server?.pendingConfirmToken ?? null, + expectedPath: view.data?.configPath ?? null, + }, + }); + 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)); + +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(); +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..b947c7c6 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/client/index.html @@ -0,0 +1,83 @@ + + + + + + MCP OAuth 令牌台 + + + +
+
+

MCP OAuth 令牌台

+

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

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

设置

+

—

+
+
+ +

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

+ +

默认拒绝私有网段、链路本地与实例元数据地址。局域网里的 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..1b69e9c6 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/client/styles.css @@ -0,0 +1,768 @@ +/* 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); +} + +.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 { + 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); +} + +/* 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); +} + +.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/errors.mjs b/plugins/microbiosis/mcp-oauth-console/miniapp/node/errors.mjs new file mode 100644 index 00000000..79a69492 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/node/errors.mjs @@ -0,0 +1,101 @@ +// @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: '该地址属于实例元数据或链路本地网段,已拒绝访问' }, + 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: '服务器名称含有非法字符(只允许字母、数字、点、下划线和连字符)' }, + 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/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..c0dc10ef --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/node/oauth-service.mjs @@ -0,0 +1,1688 @@ +// @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, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename, dirname, join, resolve, sep } from 'node:path'; + +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 + * conclusion for ten minutes). */ +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) { + 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 serviceError('config_not_parseable'); + 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 serviceError('config_not_parseable'); +} + +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 serviceError('config_not_parseable'); +} + +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 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); + 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`. + * + * 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 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 || parent === home) break; + dir = parent; + } + return join(home, MINIMAX_DIR_NAME, '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); + 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) { + // 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; +} + +/** + * 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); + }); +} + +/** + * 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; +} + +/** + * 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. + * + * `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. + * + * 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, 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, { ...currentInit, 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 (sensitive) throw serviceError('redirect_not_allowed'); + if (hop === MAX_REDIRECT_HOPS) throw serviceError('too_many_redirects'); + 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'); +} + +/** + * 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. `sensitive` marks a + * request whose body holds a credential: it is never redirected. + */ +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, sensitive); + 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'); +} + +/** + * 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, options = {}) { + return httpJson(url, init, { attempts: 1, ...options }); +} + +/** + * 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); +} + +/** 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, options = {}) { + const { allowPrivateNetwork = false } = options; + 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'); + } + 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; +} + +/** 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. + * + * 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'; + // 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 }), + { 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. + // 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( + assertMetadataUrl(resourceMatch[1]).toString(), + { headers: { accept: 'application/json' } }, + { guard: strictHop }, + ); + 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 { + // 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' } }, + { guard: strictHop }, + ); + 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, 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']; + 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: requested, + 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), + }, + // 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}`); + } + return response.json; +} + +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); + 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) { + 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')}`; + } + const response = await httpJson( + metadata.token_endpoint, + { + method: 'POST', + headers, + body: form.toString(), + }, + // 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}`; + 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, options = {}) { + const isSse = transport === 'sse'; + try { + const response = await httpJson( + url, + probeRequest(url, { transport, token }), + { 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) { + return { status: 0, ok: false, error: error instanceof Error ? error.message : 'probe_failed' }; + } +} + +/* ------------------------------------------------------------------ */ +/* Service */ +/* ------------------------------------------------------------------ */ + +export function createOAuthService({ dataDir, baseUrl, logger, onChange }) { + // 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 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(); + /** serverName -> discovery cache */ + 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(); + + 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(await getConfigPath()); + } 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 = credentialPath(serverName, credentialDir); + 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 = credentialPath(serverName, await getLegacyCredentialDir()); + 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 = credentialPath(serverName, credentialDir); + 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; + const { allowPrivateNetwork } = await loadSettings(); + try { + const value = await discoverAuthorization(url, { transport, allowPrivateNetwork }); + 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, + pendingConfirmToken: grant?.confirmToken ?? null, + lastError: grant?.error ?? null, + }); + } + const now = Date.now(); + return { + now, + configError, + configPath: await getConfigPath(), + settings: await loadSettings(), + 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, + }, + }; + } + + /** + * 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 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 started; + } 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'); + // 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); + 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 = await 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'); + // 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', + 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(), + // 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}`); + 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(), + confirmToken: randomBytes(16).toString('hex'), + }); + 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, 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 = { + 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, + confirmed: true, + expectedPath, + }); + awaitingConfirmation.delete(serverName); + const { servers: configured } = await listConfiguredServers(); + const transport = configured.find((item) => item.name === serverName)?.type; + 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, + 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 { 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, + 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. + * + * 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, options = {}) { + const { url, confirmed = false, expectedPath = null } = options; + assertServerName(serverName); + const bearer = `Bearer ${token}`; + + 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 }, + })}`; + 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 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'); + } + // 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, + replaced, + previousFingerprint: previous ? fingerprint(previous.replace(/^\S+\s+/, '')) : null, + }; + }); + } + + /** Refreshes every server whose token is inside the refresh window. */ + async function refreshExpiring({ force = false } = {}) { + const { autoRefresh } = await loadSettings(); + if (!autoRefresh && !force) { + return []; + } + 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; + } + + /** + * 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'); + 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; + 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( + assertMetadataUrl(metadata.device_authorization_endpoint).toString(), + { + method: 'POST', + headers: deviceHeaders, + body: form.toString(), + }, + // 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)}`); + 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() }, + // The body carries the client secret. + { guard: strictHop, sensitive: true }, + ); + 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(), + confirmToken: randomBytes(16).toString('hex'), + }); + 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(); + } catch { + // child already exited + } + } + children.clear(); + pending.clear(); + awaitingConfirmation.clear(); + discoveryCache.clear(); + } + + return { + getConfigPath, + loadSettings, + saveSettings, + credentialDir, + listConfiguredServers, + buildState, + probeDiscovery, + startAuthorization, + completeAuthorization, + 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 new file mode 100644 index 00000000..e3b5f78b --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/miniapp/node/server.mjs @@ -0,0 +1,455 @@ +// @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}`; + + /** @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 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, { + '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}`); + // 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(); + }); + }); + + 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/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) { + 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 ?? ''), { + 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) { + 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') { + // 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; + } + + 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 escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[char]); +} + +function renderCallbackPage(result) { + const ok = result.ok; + // 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 ` + + + + + ${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" + } +} 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/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/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..18976b10 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/tests/entry-loads.test.mjs @@ -0,0 +1,46 @@ +// 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(', ')}`); +}); + +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..796937f7 --- /dev/null +++ b/plugins/microbiosis/mcp-oauth-console/tests/redirect-guard.test.mjs @@ -0,0 +1,195 @@ +// 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'; + +import { + assertMetadataUrl, + assertTargetUrl, + guardedRequest, + 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'], + ['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', + ); +}); + +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)); + } +}); + +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)); + } +}); 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); +});