From b70bc39dfb105fe84f7f26d85ab03ca5d2524ee6 Mon Sep 17 00:00:00 2001
From: Crokily
Date: Sun, 12 Jul 2026 17:57:07 +1000
Subject: [PATCH 1/6] fix(search): include English pages in the en search index
source.getPages() without a locale argument only returns default-locale
(zh) pages, so the static /search.en.json index has been empty since the
i18n split. Enumerate pages with getPages("en") and classify English
pages by the .en.md(x) file suffix in addition to lang frontmatter, so
translations missing lang: en are indexed and excluded from the zh shard.
---
app/search.en.json/route.ts | 4 ++--
lib/search-index.ts | 6 +++---
tests/search-index-locale.test.ts | 26 ++++++++++++++++++++++++++
3 files changed, 31 insertions(+), 5 deletions(-)
create mode 100644 tests/search-index-locale.test.ts
diff --git a/app/search.en.json/route.ts b/app/search.en.json/route.ts
index d3670f64..0e24cc8d 100644
--- a/app/search.en.json/route.ts
+++ b/app/search.en.json/route.ts
@@ -5,12 +5,12 @@ import { pageToIndex, isEnglishPage } from "@/lib/search-index";
export const dynamic = "force-static";
/**
- * 英文搜索索引分片:只包含 lang==="en" 的翻译版文档,
+ * 英文搜索索引分片:按 frontmatter 或 .en 文件名识别翻译版文档,
* 用 Orama 默认英文分词(无需 mandarin tokenizer)。
*/
const api = createSearchAPI("advanced", {
indexes: () =>
- Promise.all(source.getPages().filter(isEnglishPage).map(pageToIndex)),
+ Promise.all(source.getPages("en").filter(isEnglishPage).map(pageToIndex)),
language: "english",
search: {
threshold: 0.3,
diff --git a/lib/search-index.ts b/lib/search-index.ts
index c76198cf..7c64475a 100644
--- a/lib/search-index.ts
+++ b/lib/search-index.ts
@@ -39,9 +39,9 @@ export async function pageToIndex(page: Page): Promise {
/**
* 判断一个 fumadocs 页面是否为英文翻译版。
- * 翻译版 frontmatter 会声明 `lang: "en"` 且通常 `translatedFrom: "zh"`。
+ * 兼容 frontmatter 语言标记和 Fumadocs 保留的原始 source path。
*/
-export function isEnglishPage(page: Page): boolean {
+export function isEnglishPage(page: { data: unknown; path: string }): boolean {
const lang = (page.data as PageData).lang;
- return lang === "en";
+ return lang === "en" || /\.en\.mdx?$/i.test(page.path);
}
diff --git a/tests/search-index-locale.test.ts b/tests/search-index-locale.test.ts
new file mode 100644
index 00000000..55c14c6c
--- /dev/null
+++ b/tests/search-index-locale.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/source", () => ({ source: { getPages: () => [] } }));
+
+import { isEnglishPage } from "@/lib/search-index";
+
+describe("search index locale classification", () => {
+ it("classifies English source filenames even without lang frontmatter", () => {
+ expect(
+ isEnglishPage({
+ path: "learn/cs/data-structures/array/index.en.mdx",
+ data: {},
+ }),
+ ).toBe(true);
+ expect(isEnglishPage({ path: "career/example.en.md", data: {} })).toBe(
+ true,
+ );
+ expect(isEnglishPage({ path: "career/example.md", data: {} })).toBe(false);
+ });
+
+ it("retains explicit lang frontmatter classification", () => {
+ expect(
+ isEnglishPage({ path: "career/legacy.mdx", data: { lang: "en" } }),
+ ).toBe(true);
+ });
+});
From 78129411fdae116e90a3a3971f87187b906b9731 Mon Sep 17 00:00:00 2001
From: Crokily
Date: Sun, 12 Jul 2026 17:57:07 +1000
Subject: [PATCH 2/6] feat(mcp): add MCP server with search and publish tools
Expose /api/mcp (Streamable HTTP, stateless) with two tools:
- search: server-side Orama query over the existing zh/en Fumadocs
index pipeline, public with Upstash rate limiting
- publish: forwards to the backend POST /api/posts with the caller's
satoken (Authorization: Bearer), returning the public post URL
Credential verification is isolated in lib/mcp/auth.ts and only runs
for publish calls; invalid tokens get a 401 challenge, auth-service
outages a 503, and both backend fetches carry 10s timeouts. The route
validates body size (1 MB), JSON syntax, and rejects JSON-RPC batches
before the transport. /.well-known/oauth-protected-resource prepares
the OAuth resource-server metadata; the authorization-server rollout
plan for the backend team is documented in dev_docs/mcp_auth_upgrade.md.
---
.../oauth-protected-resource/route.ts | 22 +
app/api/mcp/route.ts | 27 +
dev_docs/mcp_auth_upgrade.md | 115 ++++
dev_docs/mcp_server.md | 153 +++++
lib/mcp/auth.ts | 129 +++++
lib/mcp/promise-cache.ts | 19 +
lib/mcp/publish.ts | 68 +++
lib/mcp/rate-limit.ts | 86 +++
lib/mcp/request.ts | 80 +++
lib/mcp/schemas.ts | 41 ++
lib/mcp/search-core.ts | 234 ++++++++
lib/mcp/search.ts | 28 +
lib/mcp/tools.ts | 105 ++++
package.json | 2 +
pnpm-lock.yaml | 547 ++++++++++++++++++
tests/mcp-auth.test.ts | 155 +++++
tests/mcp-promise-cache.test.ts | 18 +
tests/mcp-publish.test.ts | 99 ++++
tests/mcp-request.test.ts | 62 ++
tests/mcp-schemas.test.ts | 58 ++
tests/mcp-search-core.test.ts | 79 +++
tests/mcp-tools.test.ts | 50 ++
22 files changed, 2177 insertions(+)
create mode 100644 app/.well-known/oauth-protected-resource/route.ts
create mode 100644 app/api/mcp/route.ts
create mode 100644 dev_docs/mcp_auth_upgrade.md
create mode 100644 dev_docs/mcp_server.md
create mode 100644 lib/mcp/auth.ts
create mode 100644 lib/mcp/promise-cache.ts
create mode 100644 lib/mcp/publish.ts
create mode 100644 lib/mcp/rate-limit.ts
create mode 100644 lib/mcp/request.ts
create mode 100644 lib/mcp/schemas.ts
create mode 100644 lib/mcp/search-core.ts
create mode 100644 lib/mcp/search.ts
create mode 100644 lib/mcp/tools.ts
create mode 100644 tests/mcp-auth.test.ts
create mode 100644 tests/mcp-promise-cache.test.ts
create mode 100644 tests/mcp-publish.test.ts
create mode 100644 tests/mcp-request.test.ts
create mode 100644 tests/mcp-schemas.test.ts
create mode 100644 tests/mcp-search-core.test.ts
create mode 100644 tests/mcp-tools.test.ts
diff --git a/app/.well-known/oauth-protected-resource/route.ts b/app/.well-known/oauth-protected-resource/route.ts
new file mode 100644
index 00000000..e1a45a54
--- /dev/null
+++ b/app/.well-known/oauth-protected-resource/route.ts
@@ -0,0 +1,22 @@
+import { getPublicOrigin } from "mcp-handler";
+
+const corsHeaders = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
+ "Access-Control-Allow-Headers": "authorization, content-type",
+};
+
+export function GET(request: Request): Response {
+ return Response.json(
+ {
+ resource: `${getPublicOrigin(request)}/api/mcp`,
+ bearer_methods_supported: ["header"],
+ scopes_supported: ["publish"],
+ },
+ { headers: corsHeaders },
+ );
+}
+
+export function OPTIONS(): Response {
+ return new Response(null, { status: 204, headers: corsHeaders });
+}
diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts
new file mode 100644
index 00000000..468e5b65
--- /dev/null
+++ b/app/api/mcp/route.ts
@@ -0,0 +1,27 @@
+import { createMcpHandler } from "mcp-handler";
+import { protectMcpHandler } from "@/lib/mcp/auth";
+import { createMcpPostHandler } from "@/lib/mcp/request";
+import { registerMcpTools } from "@/lib/mcp/tools";
+
+export const runtime = "nodejs";
+export const maxDuration = 60;
+
+const mcpHandler = protectMcpHandler(
+ createMcpHandler(
+ registerMcpTools,
+ {
+ serverInfo: { name: "involutionhell", version: "0.1.0" },
+ instructions:
+ "Use this server to search the InvolutionHell documentation community and publish lightweight Markdown posts. Search is public and supports Chinese or English article indexes. Publish writes to the authenticated user's InvolutionHell account and requires an Authorization Bearer satoken.",
+ },
+ {
+ basePath: "/api",
+ disableSse: true,
+ sessionIdGenerator: undefined,
+ maxDuration,
+ },
+ ),
+);
+
+export const GET = (request: Request): Promise => mcpHandler(request);
+export const POST = createMcpPostHandler(mcpHandler);
diff --git a/dev_docs/mcp_auth_upgrade.md b/dev_docs/mcp_auth_upgrade.md
new file mode 100644
index 00000000..51e57818
--- /dev/null
+++ b/dev_docs/mcp_auth_upgrade.md
@@ -0,0 +1,115 @@
+# MCP Auth 升级方案
+
+## 当前基础
+
+MCP 的公开 `search` 不需要登录,`publish` 使用 `Authorization: Bearer `。Next.js 侧已经准备好两处稳定边界:
+
+1. `verifyToken` 是唯一 token 校验入口,当前调用 Spring Boot `/auth/me`;
+2. `/.well-known/oauth-protected-resource` 已存在,当前声明 resource 与 `publish` scope,等授权服务器上线后补 `authorization_servers`。
+
+工具层不判断 token 类型,只使用鉴权层给出的已验证身份和 backend headers。因此 satoken 与 OAuth 可以并行,迁移时不改 MCP tool schema。
+
+## 路线 A:Spring Boot 在 sa-token 上实现 OAuth 2.1
+
+这条路线由现有后端同时承担 Authorization Server 与 Resource Server。用户登录、账号关联、权限判断继续复用当前 sa-token 数据。
+
+### Endpoint checklist
+
+| Endpoint | 责任 |
+| --------------------------------------------- | ------------------------------------------------------------------- |
+| `GET /.well-known/oauth-authorization-server` | 发布 issuer、authorization/token/registration endpoint、PKCE 能力 |
+| `GET /oauth/authorize` | 校验 client、redirect URI、resource、scope、state、PKCE challenge |
+| `POST /oauth/authorize` | 用户确认授权后签发一次性 authorization code |
+| `POST /oauth/token` | 用 code + PKCE verifier 换 access token,可选签发 refresh token |
+| `POST /oauth/introspect` | 给 MCP resource server 校验 token、scope、subject、audience、expiry |
+| `POST /oauth/revoke` | 撤销 access/refresh token |
+| `POST /oauth/register` | 如果要支持动态客户端注册,登记 redirect URIs 与 client metadata |
+| `GET /oauth/jwks` | 使用 JWT access token 时发布签名公钥 |
+
+最低安全要求:
+
+- 只允许 Authorization Code + PKCE S256;
+- 不提供 implicit flow 和 password grant;
+- redirect URI 精确匹配,不接受 wildcard;
+- code 单次使用且短 TTL;
+- access token 必须绑定 `https://involutionhell.com/api/mcp` resource/audience;
+- scope 至少区分 `publish`,默认不能扩大权限;
+- refresh token rotation,并检测 reuse;
+- state 必须原样校验,浏览器授权流程同时防 CSRF;
+- token、code、verifier 不进入应用日志、错误响应和 tracing attribute。
+
+如果继续签发 opaque token,Next.js `verifyToken` 调 introspection;如果签发 JWT,Next.js 可用 issuer/JWKS 本地验证,但 Java `/api/posts` 也必须验证同一 issuer、audience、expiry 与 `publish` scope。
+
+## 路线 B:第三方 IdP,后端只做 access-token validation
+
+Stytch、WorkOS 或 Auth0 承担登录、consent、authorize、token、refresh、revoke、JWKS 和可选动态客户端注册。Spring Boot 不签发 OAuth token,只把自己作为 Resource Server。
+
+### Endpoint checklist
+
+IdP 侧必须提供:
+
+| Endpoint | 责任 |
+| ----------------------------------------------------- | --------------------------- |
+| `/.well-known/openid-configuration` 或 OAuth metadata | issuer 与能力发现 |
+| `/authorize` | Authorization Code + PKCE |
+| `/token` | code/refresh token exchange |
+| `/jwks.json` | JWT 签名公钥 |
+| `/revoke` | token 撤销 |
+| `/register`(如启用 DCR) | MCP client 动态注册 |
+
+Java 后端需要增加:
+
+| Endpoint/组件 | 责任 |
+| ----------------------------------------- | ------------------------------------------------------------------ |
+| OAuth bearer filter | 读取 `Authorization: Bearer`,与 legacy `satoken` filter 并行 |
+| JWT validator 或 IdP introspection client | 校验 issuer、签名、audience/resource、expiry、not-before、scope |
+| subject-to-user mapper | 把 IdP `sub` 稳定映射到 `user_accounts`,禁止按可变 email 临时认人 |
+| `GET /auth/me` | 同时接受 satoken 与 OAuth token,返回同一 `UserView` |
+| `POST /api/posts` | 两种 token 都走同一用户身份与 `publish` 权限检查 |
+
+IdP tenant 必须把 MCP production resource 配成独立 audience,不要接受发给其他 API 的 access token。账号关联、停用用户、角色变化和 token 撤销后的生效时间要在后端明确。
+
+## 迁移与共存
+
+建议分四阶段:
+
+1. **准备**:保持 `satoken` header 与现有 `Authorization: Bearer ` MCP 用法;后端测试双 filter 的优先级和冲突处理。
+2. **并行**:`/auth/me`、`/api/posts` 同时接受 satoken 和 OAuth access token。一个请求只采用一种身份;如果两个 header 指向不同用户,必须拒绝,不能猜优先级。
+3. **MCP 切换**:Next.js `verifyToken` 先识别/验证 OAuth,legacy satoken 作为 fallback。protected-resource metadata 增加真实 `authorization_servers`,客户端逐步重连 OAuth。
+4. **收口**:观察 legacy 使用量,给出明确下线日期;撤掉 MCP 的 satoken 支持前,网页现有 satoken 登录不必同步下线。
+
+并行期要保持两条 header 路径:
+
+```http
+satoken:
+Authorization: Bearer
+```
+
+不要把 OAuth token 复制进 `satoken` header 来假装兼容。两类 token 的 issuer、audience、scope、撤销语义不同,后端应该显式验证。
+
+## Backend 测试义务
+
+每个安全不变量都必须有成对测试:一个合法请求通过,一个只破坏该不变量的请求失败。至少覆盖:
+
+- PKCE S256 正确 verifier / 错误 verifier;
+- redirect URI 精确匹配 / 相似域名或 wildcard 拒绝;
+- state 保持 / state 缺失或篡改;
+- code 首次兑换 / 重放;
+- 正确 resource/audience / 错 audience;
+- `publish` scope 存在 / 缺 scope;
+- 未过期 token / expired、not-before、revoked token;
+- 正确 issuer/签名 / 错 issuer、未知 key、算法降级;
+- 已绑定 `sub` / 未绑定或停用用户;
+- satoken 单独请求、OAuth 单独请求、双 header 同用户、双 header 冲突用户;
+- introspection/JWKS 超时、5xx、malformed payload 时 fail closed;
+- 日志和错误响应不包含 token、code、refresh token 或 verifier。
+
+所有新增后端代码必须执行:
+
+```bash
+./mvnw verify
+```
+
+JaCoCo line coverage 不低于 80%。`SECURITY.md` 中新增或修改的每条 invariant 都要同时落一组正向/反向测试;不能只用 controller happy-path 把覆盖率刷到 80%。集成测试还要真实经过 security filter chain,不能全部 mock 掉 token validator。
+
+上线前至少再做一次跨服务 E2E:客户端 authorize → token → MCP initialize → `search` → `publish` → Java 创建 PostView,并验证错误 audience、缺 scope、过期 token 都在写数据库前被拒绝。
diff --git a/dev_docs/mcp_server.md b/dev_docs/mcp_server.md
new file mode 100644
index 00000000..bdd823d6
--- /dev/null
+++ b/dev_docs/mcp_server.md
@@ -0,0 +1,153 @@
+# MCP Server
+
+## 目标
+
+本站在 Next.js 内提供 `/api/mcp`,让支持 MCP Streamable HTTP 的客户端直接完成两件事:
+
+- 搜索 `content/docs/**/*.mdx` 中的站内文章;
+- 使用已登录用户的凭证发布一篇轻量 Markdown 帖子。
+
+MCP route 与文档站一起部署到 Vercel,不需要增加独立 Node 服务,也不会复制一套内容同步流程。它直接读取构建时生成的 Fumadocs source,并通过 `BACKEND_URL` 调用现有 Spring Boot API。
+
+## Transport 与运行模型
+
+服务端使用 `mcp-handler@1.1.0` 和 `@modelcontextprotocol/sdk@1.26.0`:
+
+- 只开放 Streamable HTTP;
+- `sessionIdGenerator` 为 `undefined`,每个请求创建独立 MCP server/transport;
+- 关闭 legacy SSE endpoint;
+- 不保存 session,不依赖 Redis,可运行在 Vercel serverless;
+- `GET /api/mcp` 在 stateless 模式返回 405,`POST /api/mcp` 处理 JSON-RPC。
+
+`POST` 会在进入 `mcp-handler` 前读取并校验原始 body:超过 1 MB 返回 HTTP 413,
+非法 JSON 返回 HTTP 400 / JSON-RPC `-32700`。MCP 2025-06-18 起不支持 batch,
+所以顶层数组返回 HTTP 400 / JSON-RPC `-32600`,不会交给旧版 SDK 执行。
+
+仓库使用 zod 4。`mcp-handler` README 仍写 zod 3,但它配套的 SDK 1.26.0 已明确兼容 zod 3.25 和 zod 4。本实现固定 SDK 1.26.0 以满足 `mcp-handler@1.1.0` 的精确 peer dependency,并用 zod 4 schema 实测通过了 TypeScript、`tools/list` schema 生成和 `tools/call` 运行时校验,因此没有降级 zod,也没有改用 raw JSON Schema。
+
+## Tool contract
+
+### `search`
+
+输入:
+
+| 字段 | 类型 | 默认值 | 约束 |
+| -------- | ------------ | ------ | ------------------------- |
+| `query` | string | 无 | 去除首尾空白后 1–200 字符 |
+| `locale` | `"zh"\|"en"` | `zh` | 选择中文或英文索引 |
+| `limit` | integer | `8` | 1–20 |
+
+输出的 `structuredContent.results` 是:
+
+```ts
+Array<{
+ title: string;
+ description: string;
+ url: string;
+ snippet: string;
+}>;
+```
+
+`url` 固定为 `https://involutionhell.com` 下的绝对地址。`snippet` 从页面 `structuredData.contents` 中选择与 query 最相关的段落,并截断到 300 字符以内。文本 `content` 同时提供同一批结果,兼容不读取 structured output 的客户端。
+
+搜索索引沿用现有 `/search.zh.json` 和 `/search.en.json` 的数据管线:
+
+1. `source.getPages()`;
+2. 按 `isEnglishPage()` 分中文、英文 shard;`lang: en` 或源文件名以 `.en.md` / `.en.mdx` 结尾都视为英文;
+3. 使用 `pageToIndex()` 取得 title、description、URL 和 structured data;
+4. 中文 shard 使用 Orama Mandarin tokenizer,英文 shard 使用 English language;
+5. 查询保持 `threshold: 0.3`、`tolerance: 1`,并按 page 聚合结果;
+6. exact phrase 命中的页面优先按 title、description、heading、content 加权重排,
+ 再补 Orama fuzzy hits,避免短中文词在编辑距离为 1 时被近形词排到前面。
+
+搜索实现和两个 shard 都是 module-level lazy load。同一 lambda 实例第一次搜索时才加载内容 chunk,第一次搜索某种语言时建对应索引,后续请求复用;构建失败会清除 promise,让下次请求重试。
+
+### `publish`
+
+输入:
+
+| 字段 | 类型 | 必填 | 说明 |
+| ------------- | -------- | ---- | -------------------------------- |
+| `title` | string | 是 | 帖子标题,最多 200 字符 |
+| `content_md` | string | 是 | Markdown 正文,最多 100,000 字符 |
+| `description` | string | 否 | 摘要,最多 500 字符 |
+| `tags` | string[] | 否 | 最多 10 个,每个最多 50 字符 |
+| `slug` | string | 否 | 最多 100 字符;不传时由后端生成 |
+
+成功输出 `structuredContent`:
+
+```ts
+{
+ title: string;
+ slug: string;
+ url: string;
+}
+```
+
+MCP route 将字段映射成 `{ title, contentMd, description?, tags?, slug? }`,调用 `POST ${BACKEND_URL}/api/posts`,超时为 10 秒。后端 409 会提示更换 slug;401/403 会提示重新登录取得 satoken;超时和其他错误不会把后端响应体或凭证回显给客户端。
+
+## 鉴权边界
+
+`search` 完全匿名可用。`publish` 要求 MCP HTTP 请求携带:
+
+```http
+Authorization: Bearer
+```
+
+所有凭证读取、`${BACKEND_URL}/auth/me` 校验、MCP `AuthInfo` 生成和后端 header 生成都集中在 `lib/mcp/auth.ts`。只有解析后的 `publish` tool call 才校验 bearer token;initialize、tools/list、search 等请求即使带 bearer header 也匿名执行,不访问鉴权后端。工具只接收“已验证身份 + 后端 headers”,不解析 Authorization,也不记录 token。
+
+当前行为:
+
+- 匿名调用 `search`:正常执行;
+- 匿名调用 `publish`:HTTP 401,并返回 `WWW-Authenticate`;
+- `publish` 携带无效或过期 token:HTTP 401,并返回同一 `WWW-Authenticate` challenge;
+- `publish` 携带有效 token:先调用 `/auth/me`,再执行 `publish`;
+- `/auth/me` 超时、网络失败或返回 5xx:HTTP 503 通用错误,提示稍后重试,不把有效凭证误报为无效。`/auth/me` 超时为 10 秒。
+
+`/.well-known/oauth-protected-resource` 已声明 MCP resource、header bearer method 和 `publish` scope。当前后端尚无 OAuth issuer,因此 metadata 不虚构 `authorization_servers`;接入 OAuth 时按升级文档补齐。
+
+sa-token 当前有效期是 30 天。网页端暂时没有安全的 OAuth 授权流程,浏览器类 MCP 客户端默认只使用匿名 `search`;需要 `publish` 时由用户在可信本地客户端显式配置 token。
+
+## 限流
+
+`search` 使用独立的 Upstash sliding window:每 IP 每 60 秒 30 次,key prefix 为 `ratelimit:mcp:search`。环境变量读取顺序与站内现有 limiter 一致:
+
+- `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`;
+- Vercel prefix 变体;
+- `KV_REST_API_URL` / `KV_REST_API_TOKEN`。
+
+本地没有 Upstash 环境变量时允许全部请求,并且每个模块实例只打印一次提示。`publish` 依赖登录身份和后端自身保护,不占 search 的额度。
+
+## 本地开发
+
+```bash
+corepack pnpm check:pnpm-version
+BACKEND_URL=http://localhost:8080 corepack pnpm dev
+```
+
+匿名连接 Claude Code:
+
+```bash
+claude mcp add --transport http involutionhell https://involutionhell.com/api/mcp
+```
+
+需要发布时,将 satoken 放进环境变量,避免写进 shell history:
+
+```bash
+read -s INVOLUTIONHELL_SATOKEN
+export INVOLUTIONHELL_SATOKEN
+claude mcp remove involutionhell
+claude mcp add --transport http involutionhell https://involutionhell.com/api/mcp \
+ --header "Authorization: Bearer ${INVOLUTIONHELL_SATOKEN}"
+```
+
+本地 endpoint 可替换为 `http://localhost:3000/api/mcp`。
+
+## 已知限制
+
+- satoken 最长 30 天,过期后需要重新登录并更新客户端配置;
+- 当前没有 OAuth refresh token,不能静默续期;
+- web MCP client 在 OAuth 上线前只应开放匿名 search;
+- shard 缓存只在单个 lambda 实例内有效,冷启动会重新构建;
+- 内容更新随下一次部署进入 MCP index,不是运行时增量索引;
+- `publish` 只处理轻量帖子,不上传 cover 或正文图片。
diff --git a/lib/mcp/auth.ts b/lib/mcp/auth.ts
new file mode 100644
index 00000000..474da2b8
--- /dev/null
+++ b/lib/mcp/auth.ts
@@ -0,0 +1,129 @@
+import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
+import { getPublicOrigin } from "mcp-handler";
+
+const RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
+
+interface BackendUser {
+ id: number;
+ username: string;
+}
+
+interface VerifiedIdentity {
+ user: BackendUser;
+ backendHeaders: Record;
+}
+
+function backendUrl(): string {
+ return process.env.BACKEND_URL ?? "http://localhost:8080";
+}
+
+export async function verifyToken(
+ _request: Request,
+ bearerToken?: string,
+): Promise {
+ if (!bearerToken) return undefined;
+
+ const response = await fetch(`${backendUrl()}/auth/me`, {
+ headers: { satoken: bearerToken },
+ signal: AbortSignal.timeout(10_000),
+ });
+ if (response.status === 401 || response.status === 403) return undefined;
+ if (!response.ok) {
+ throw new Error(`Authentication backend returned HTTP ${response.status}`);
+ }
+
+ const body = (await response.json()) as { data?: Partial };
+ if (typeof body.data?.id !== "number" || !body.data.username) {
+ throw new Error("Authentication backend returned an invalid user payload");
+ }
+
+ return {
+ token: bearerToken,
+ clientId: `involutionhell-user:${body.data.id}`,
+ scopes: ["publish"],
+ extra: {
+ user: {
+ id: body.data.id,
+ username: body.data.username,
+ },
+ },
+ };
+}
+
+export function getVerifiedIdentity(
+ authInfo: AuthInfo | undefined,
+): VerifiedIdentity | undefined {
+ const user = authInfo?.extra?.user as BackendUser | undefined;
+ if (!authInfo || typeof user?.id !== "number" || !user.username) {
+ return undefined;
+ }
+ return {
+ user,
+ backendHeaders: { satoken: authInfo.token },
+ };
+}
+
+export function isPublishCall(body: unknown): boolean {
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
+ const request = body as {
+ method?: unknown;
+ params?: { name?: unknown };
+ };
+ return request.method === "tools/call" && request.params?.name === "publish";
+}
+
+function hasBearerToken(request: Request): boolean {
+ const authorization = request.headers.get("authorization")?.trim();
+ return /^Bearer\s+\S+$/i.test(authorization ?? "");
+}
+
+function invalidTokenResponse(request: Request): Response {
+ const metadataUrl = `${getPublicOrigin(request)}${RESOURCE_METADATA_PATH}`;
+ const description =
+ "Authorization: Bearer is required to call publish";
+ return Response.json(
+ { error: "invalid_token", error_description: description },
+ {
+ status: 401,
+ headers: {
+ "WWW-Authenticate": `Bearer error="invalid_token", error_description="${description}", resource_metadata="${metadataUrl}"`,
+ },
+ },
+ );
+}
+
+function authUnavailableResponse(): Response {
+ return Response.json(
+ {
+ error: "temporarily_unavailable",
+ error_description: "Authentication service unavailable. Try again later.",
+ },
+ { status: 503 },
+ );
+}
+
+export function protectMcpHandler(
+ handler: (request: Request) => Response | Promise,
+): (request: Request, body?: unknown) => Promise {
+ return async (request, body) => {
+ if (request.method !== "POST" || !isPublishCall(body)) {
+ return handler(request);
+ }
+ if (!hasBearerToken(request)) {
+ return invalidTokenResponse(request);
+ }
+
+ const authorization = request.headers.get("authorization")!.trim();
+ const bearerToken = authorization.replace(/^Bearer\s+/i, "");
+ let authInfo: AuthInfo | undefined;
+ try {
+ authInfo = await verifyToken(request, bearerToken);
+ } catch {
+ return authUnavailableResponse();
+ }
+ if (!authInfo) return invalidTokenResponse(request);
+
+ request.auth = authInfo;
+ return handler(request);
+ };
+}
diff --git a/lib/mcp/promise-cache.ts b/lib/mcp/promise-cache.ts
new file mode 100644
index 00000000..74948980
--- /dev/null
+++ b/lib/mcp/promise-cache.ts
@@ -0,0 +1,19 @@
+export function createRetryablePromiseCache(
+ builder: (key: Key) => Promise,
+): (key: Key) => Promise {
+ const cache: Partial>> = {};
+
+ return (key) => {
+ const cached = cache[key];
+ if (cached) return cached;
+
+ const promise = Promise.resolve()
+ .then(() => builder(key))
+ .catch((error) => {
+ if (cache[key] === promise) delete cache[key];
+ throw error;
+ });
+ cache[key] = promise;
+ return promise;
+ };
+}
diff --git a/lib/mcp/publish.ts b/lib/mcp/publish.ts
new file mode 100644
index 00000000..f4c36c57
--- /dev/null
+++ b/lib/mcp/publish.ts
@@ -0,0 +1,68 @@
+import type { ApiResponse, PostView } from "@/app/types/post";
+import type { PublishInput, PublishOutput } from "@/lib/mcp/schemas";
+
+const SITE_ORIGIN = "https://involutionhell.com";
+
+export class PublishError extends Error {}
+
+export async function publishPost(
+ input: PublishInput,
+ backendHeaders: Record,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ let response: Response;
+ try {
+ response = await fetchImpl(
+ `${process.env.BACKEND_URL ?? "http://localhost:8080"}/api/posts`,
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ ...backendHeaders,
+ },
+ body: JSON.stringify({
+ title: input.title,
+ contentMd: input.content_md,
+ ...(input.description === undefined
+ ? {}
+ : { description: input.description }),
+ ...(input.tags === undefined ? {} : { tags: input.tags }),
+ ...(input.slug === undefined ? {} : { slug: input.slug }),
+ }),
+ signal: AbortSignal.timeout(10_000),
+ },
+ );
+ } catch {
+ throw new PublishError(
+ "Could not reach the InvolutionHell backend. Try again later.",
+ );
+ }
+
+ if (response.status === 409) {
+ throw new PublishError(
+ "The slug already exists. Pick another slug and try again.",
+ );
+ }
+ if (response.status === 401 || response.status === 403) {
+ throw new PublishError(
+ "Your token is expired or invalid. Sign in to InvolutionHell again to obtain a new satoken, then reconnect with Authorization: Bearer .",
+ );
+ }
+ if (!response.ok) {
+ throw new PublishError(
+ `The InvolutionHell backend could not publish the post (HTTP ${response.status}). Try again later.`,
+ );
+ }
+
+ const body = (await response.json()) as ApiResponse;
+ if (!body.data?.slug || !body.data.authorUsername || !body.data.title) {
+ throw new PublishError("The backend returned an invalid publish response.");
+ }
+
+ const path = `/u/${encodeURIComponent(body.data.authorUsername)}/posts/${encodeURIComponent(body.data.slug)}`;
+ return {
+ title: body.data.title,
+ slug: body.data.slug,
+ url: new URL(path, SITE_ORIGIN).toString(),
+ };
+}
diff --git a/lib/mcp/rate-limit.ts b/lib/mcp/rate-limit.ts
new file mode 100644
index 00000000..a1533ea3
--- /dev/null
+++ b/lib/mcp/rate-limit.ts
@@ -0,0 +1,86 @@
+import { Ratelimit } from "@upstash/ratelimit";
+import { Redis } from "@upstash/redis";
+import type { RequestInfo } from "@modelcontextprotocol/sdk/types.js";
+
+let cachedLimiter: Ratelimit | null | undefined;
+let warnedMissingUpstash = false;
+
+function firstEnv(...names: string[]): string | undefined {
+ for (const name of names) {
+ const value = process.env[name];
+ if (value?.trim()) return value;
+ }
+ return undefined;
+}
+
+function getLimiter(): Ratelimit | null {
+ if (cachedLimiter !== undefined) return cachedLimiter;
+ const url = firstEnv(
+ "UPSTASH_REDIS_REST_URL",
+ "UPSTASH_REDIS_REST_KV_REST_API_URL",
+ "KV_REST_API_URL",
+ );
+ const token = firstEnv(
+ "UPSTASH_REDIS_REST_TOKEN",
+ "UPSTASH_REDIS_REST_KV_REST_API_TOKEN",
+ "KV_REST_API_TOKEN",
+ );
+ if (!url || !token) {
+ cachedLimiter = null;
+ return cachedLimiter;
+ }
+
+ cachedLimiter = new Ratelimit({
+ redis: new Redis({ url, token }),
+ limiter: Ratelimit.slidingWindow(30, "60 s"),
+ analytics: true,
+ prefix: "ratelimit:mcp:search",
+ });
+ return cachedLimiter;
+}
+
+function headerValue(
+ headers: RequestInfo["headers"] | undefined,
+ name: string,
+): string | undefined {
+ const entry = Object.entries(headers ?? {}).find(
+ ([key]) => key.toLowerCase() === name,
+ )?.[1];
+ return Array.isArray(entry) ? entry[0] : entry;
+}
+
+function clientIp(headers: RequestInfo["headers"] | undefined): string {
+ const realIp = headerValue(headers, "x-real-ip")?.trim();
+ if (realIp) return realIp;
+ const forwarded = headerValue(headers, "x-forwarded-for");
+ const parts = forwarded
+ ?.split(",")
+ .map((part) => part.trim())
+ .filter(Boolean);
+ return parts?.at(-1) ?? "anonymous";
+}
+
+export async function limitMcpSearch(
+ requestInfo: RequestInfo | undefined,
+): Promise<{ success: boolean; retryAfterSeconds?: number }> {
+ const limiter = getLimiter();
+ if (!limiter) {
+ if (!warnedMissingUpstash) {
+ warnedMissingUpstash = true;
+ console.warn(
+ "[mcp] Upstash is not configured; search rate limiting is disabled",
+ );
+ }
+ return { success: true };
+ }
+
+ const result = await limiter.limit(clientIp(requestInfo?.headers));
+ if (result.success) return { success: true };
+ return {
+ success: false,
+ retryAfterSeconds: Math.max(
+ 1,
+ Math.ceil((result.reset - Date.now()) / 1000),
+ ),
+ };
+}
diff --git a/lib/mcp/request.ts b/lib/mcp/request.ts
new file mode 100644
index 00000000..7ba9153d
--- /dev/null
+++ b/lib/mcp/request.ts
@@ -0,0 +1,80 @@
+const MAX_BODY_BYTES = 1024 * 1024;
+
+type ParsedMcpHandler = (
+ request: Request,
+ body: unknown,
+) => Response | Promise;
+
+class BodyTooLargeError extends Error {}
+
+function jsonRpcError(status: number, code: number, message: string): Response {
+ return Response.json(
+ {
+ jsonrpc: "2.0",
+ error: { code, message },
+ id: null,
+ },
+ { status },
+ );
+}
+
+async function readBodyText(request: Request): Promise {
+ const contentLength = Number(request.headers.get("content-length"));
+ if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) {
+ throw new BodyTooLargeError();
+ }
+ if (!request.body) return "";
+
+ const reader = request.body.getReader();
+ const decoder = new TextDecoder();
+ let byteLength = 0;
+ let text = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ byteLength += value.byteLength;
+ if (byteLength > MAX_BODY_BYTES) {
+ await reader.cancel();
+ throw new BodyTooLargeError();
+ }
+ text += decoder.decode(value, { stream: true });
+ }
+
+ return text + decoder.decode();
+}
+
+export function createMcpPostHandler(
+ handler: ParsedMcpHandler,
+): (request: Request) => Promise {
+ return async (request) => {
+ let text: string;
+ try {
+ text = await readBodyText(request);
+ } catch (error) {
+ if (error instanceof BodyTooLargeError) {
+ return jsonRpcError(413, -32600, "Request body exceeds the 1 MB limit");
+ }
+ throw error;
+ }
+
+ let body: unknown;
+ try {
+ body = JSON.parse(text);
+ } catch {
+ return jsonRpcError(400, -32700, "Parse error");
+ }
+
+ if (Array.isArray(body)) {
+ return jsonRpcError(400, -32600, "JSON-RPC batches are not supported");
+ }
+
+ const validatedRequest = new Request(request.url, {
+ method: request.method,
+ headers: request.headers,
+ body: text,
+ signal: request.signal,
+ });
+ return handler(validatedRequest, body);
+ };
+}
diff --git a/lib/mcp/schemas.ts b/lib/mcp/schemas.ts
new file mode 100644
index 00000000..9dce7488
--- /dev/null
+++ b/lib/mcp/schemas.ts
@@ -0,0 +1,41 @@
+import { z } from "zod";
+
+export const searchInputSchema = z.object({
+ query: z.string().trim().min(1).max(200),
+ locale: z.enum(["zh", "en"]).default("zh"),
+ limit: z.number().int().min(1).max(20).default(8),
+});
+
+export const searchResultSchema = z.object({
+ title: z.string(),
+ description: z.string(),
+ url: z.string().url(),
+ snippet: z.string().max(299),
+});
+
+export const searchOutputSchema = z.object({
+ results: z.array(searchResultSchema),
+});
+
+export const publishInputSchema = z.object({
+ title: z.string().trim().min(1).max(200),
+ content_md: z
+ .string()
+ .min(1)
+ .max(100_000)
+ .refine((value) => value.trim().length > 0),
+ description: z.string().trim().min(1).max(500).optional(),
+ tags: z.array(z.string().trim().min(1).max(50)).max(10).optional(),
+ slug: z.string().trim().min(1).max(100).optional(),
+});
+
+export const publishOutputSchema = z.object({
+ title: z.string(),
+ slug: z.string(),
+ url: z.string().url(),
+});
+
+export type SearchInput = z.infer;
+export type SearchResult = z.infer;
+export type PublishInput = z.infer;
+export type PublishOutput = z.infer;
diff --git a/lib/mcp/search-core.ts b/lib/mcp/search-core.ts
new file mode 100644
index 00000000..edbf98bd
--- /dev/null
+++ b/lib/mcp/search-core.ts
@@ -0,0 +1,234 @@
+import { create, insertMultiple, search } from "@orama/orama";
+import { createTokenizer } from "@orama/tokenizers/mandarin";
+import type { AdvancedIndex } from "fumadocs-core/search/server";
+import type { StructuredData } from "fumadocs-core/mdx-plugins";
+import type { SearchResult } from "@/lib/mcp/schemas";
+
+const SITE_ORIGIN = "https://involutionhell.com";
+const MAX_SNIPPET_LENGTH = 299;
+
+const schema = {
+ id: "string",
+ page_id: "string",
+ type: "string",
+ url: "string",
+ content: "string",
+} as const;
+
+interface IndexedPage {
+ title: string;
+ description: string;
+ url: string;
+ structuredData: StructuredData;
+ normalized: {
+ title: string;
+ description: string;
+ headings: string[];
+ contents: string[];
+ };
+}
+
+export interface SearchShard {
+ db: ReturnType>;
+ pages: Map;
+}
+
+function normalizeText(value: string): string {
+ return value.replace(/\s+/g, " ").trim();
+}
+
+function normalizeForMatch(value: string): string {
+ return normalizeText(value).toLocaleLowerCase();
+}
+
+function relevantContent(
+ contents: StructuredData["contents"],
+ query: string,
+): string {
+ const normalizedQuery = normalizeForMatch(query);
+ const exact = contents.find((entry) =>
+ normalizeForMatch(entry.content).includes(normalizedQuery),
+ );
+ if (exact) return exact.content;
+
+ const terms = normalizedQuery.split(/\s+/).filter(Boolean);
+ let best = "";
+ let bestScore = 0;
+ for (const entry of contents) {
+ const text = normalizeForMatch(entry.content);
+ const score = terms.reduce(
+ (total, term) => total + (text.includes(term) ? term.length : 0),
+ 0,
+ );
+ if (score > bestScore) {
+ best = entry.content;
+ bestScore = score;
+ }
+ }
+
+ return best || contents[0]?.content || "";
+}
+
+export function extractSnippet(
+ contents: StructuredData["contents"],
+ query: string,
+): string {
+ const text = normalizeText(relevantContent(contents, query));
+ if (text.length <= MAX_SNIPPET_LENGTH) return text;
+
+ const normalizedQuery = normalizeForMatch(query);
+ const matchAt = text.toLocaleLowerCase().indexOf(normalizedQuery);
+ const contentLength = MAX_SNIPPET_LENGTH - 2;
+ const start = Math.max(
+ 0,
+ Math.min(matchAt > -1 ? matchAt - 80 : 0, text.length - contentLength),
+ );
+ const excerpt = text.slice(start, start + contentLength);
+ return `${start > 0 ? "…" : ""}${excerpt}${
+ start + contentLength < text.length ? "…" : ""
+ }`.slice(0, MAX_SNIPPET_LENGTH);
+}
+
+export function toAbsoluteSiteUrl(path: string): string {
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
+ return new URL(normalizedPath, SITE_ORIGIN).toString();
+}
+
+export async function createSearchShard(
+ indexes: AdvancedIndex[],
+ locale: "zh" | "en",
+): Promise {
+ const db = create({
+ schema,
+ ...(locale === "zh"
+ ? { components: { tokenizer: createTokenizer() } }
+ : { language: "english" }),
+ });
+ const pages = new Map();
+ const records: Array<{
+ id: string;
+ page_id: string;
+ type: string;
+ url: string;
+ content: string;
+ }> = [];
+
+ for (const page of indexes) {
+ pages.set(page.id, {
+ title: page.title,
+ description: page.description ?? "",
+ url: page.url,
+ structuredData: page.structuredData,
+ normalized: {
+ title: normalizeForMatch(page.title),
+ description: normalizeForMatch(page.description ?? ""),
+ headings: page.structuredData.headings.map((heading) =>
+ normalizeForMatch(heading.content),
+ ),
+ contents: page.structuredData.contents.map((content) =>
+ normalizeForMatch(content.content),
+ ),
+ },
+ });
+ let sequence = 0;
+ records.push({
+ id: page.id,
+ page_id: page.id,
+ type: "page",
+ url: page.url,
+ content: page.title,
+ });
+ if (page.description) {
+ records.push({
+ id: `${page.id}-${sequence++}`,
+ page_id: page.id,
+ type: "text",
+ url: page.url,
+ content: page.description,
+ });
+ }
+ for (const heading of page.structuredData.headings) {
+ records.push({
+ id: `${page.id}-${sequence++}`,
+ page_id: page.id,
+ type: "heading",
+ url: `${page.url}#${heading.id}`,
+ content: heading.content,
+ });
+ }
+ for (const content of page.structuredData.contents) {
+ records.push({
+ id: `${page.id}-${sequence++}`,
+ page_id: page.id,
+ type: "text",
+ url: content.heading ? `${page.url}#${content.heading}` : page.url,
+ content: content.content,
+ });
+ }
+ }
+
+ await insertMultiple(db, records);
+ return { db, pages };
+}
+
+export async function searchShard(
+ shard: SearchShard,
+ query: string,
+ limit: number,
+): Promise {
+ const response = await search(shard.db, {
+ term: query,
+ properties: ["content"],
+ threshold: 0.3,
+ tolerance: 1,
+ limit: Math.max(limit * 8, 40),
+ groupBy: {
+ properties: ["page_id"],
+ maxResult: 8,
+ },
+ });
+
+ const normalizedQuery = normalizeForMatch(query);
+ const exactPageIds = [...shard.pages.entries()]
+ .map(([pageId, page]) => {
+ let score = 0;
+ if (page.normalized.title.includes(normalizedQuery)) {
+ score += 1_000;
+ }
+ if (page.normalized.description.includes(normalizedQuery)) {
+ score += 300;
+ }
+ score +=
+ page.normalized.headings.filter((heading) =>
+ heading.includes(normalizedQuery),
+ ).length * 200;
+ score +=
+ page.normalized.contents.filter((content) =>
+ content.includes(normalizedQuery),
+ ).length * 100;
+ return { pageId, score };
+ })
+ .filter((entry) => entry.score > 0)
+ .sort((a, b) => b.score - a.score)
+ .map((entry) => entry.pageId);
+ const fuzzyPageIds = (response.groups ?? []).map((group) =>
+ String(group.values[0]),
+ );
+ const pageIds = [...new Set([...exactPageIds, ...fuzzyPageIds])].slice(
+ 0,
+ limit,
+ );
+
+ return pageIds.flatMap((pageId) => {
+ const page = shard.pages.get(pageId);
+ if (!page) return [];
+ return [
+ {
+ title: page.title,
+ description: page.description,
+ url: toAbsoluteSiteUrl(page.url),
+ snippet: extractSnippet(page.structuredData.contents, query),
+ },
+ ];
+ });
+}
diff --git a/lib/mcp/search.ts b/lib/mcp/search.ts
new file mode 100644
index 00000000..d21e5c7c
--- /dev/null
+++ b/lib/mcp/search.ts
@@ -0,0 +1,28 @@
+import { source } from "@/lib/source";
+import { isEnglishPage, pageToIndex } from "@/lib/search-index";
+import {
+ createSearchShard,
+ searchShard,
+ type SearchShard,
+} from "@/lib/mcp/search-core";
+import type { SearchInput, SearchResult } from "@/lib/mcp/schemas";
+import { createRetryablePromiseCache } from "@/lib/mcp/promise-cache";
+
+const getShard = createRetryablePromiseCache<
+ SearchInput["locale"],
+ SearchShard
+>(async (locale) => {
+ const pages = source
+ .getPages(locale)
+ .filter((page) =>
+ locale === "en" ? isEnglishPage(page) : !isEnglishPage(page),
+ );
+ const indexes = await Promise.all(pages.map(pageToIndex));
+ return createSearchShard(indexes, locale);
+});
+
+export async function searchSiteArticles(
+ input: SearchInput,
+): Promise {
+ return searchShard(await getShard(input.locale), input.query, input.limit);
+}
diff --git a/lib/mcp/tools.ts b/lib/mcp/tools.ts
new file mode 100644
index 00000000..887a5368
--- /dev/null
+++ b/lib/mcp/tools.ts
@@ -0,0 +1,105 @@
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { getVerifiedIdentity } from "@/lib/mcp/auth";
+import { PublishError, publishPost } from "@/lib/mcp/publish";
+import { limitMcpSearch } from "@/lib/mcp/rate-limit";
+import {
+ publishInputSchema,
+ publishOutputSchema,
+ searchInputSchema,
+ searchOutputSchema,
+} from "@/lib/mcp/schemas";
+
+export function registerMcpTools(server: McpServer): void {
+ server.registerTool(
+ "search",
+ {
+ title: "Search InvolutionHell articles",
+ description:
+ "Search the InvolutionHell documentation corpus. Use locale zh for Chinese articles and en for English translations.",
+ inputSchema: searchInputSchema,
+ outputSchema: searchOutputSchema,
+ annotations: { readOnlyHint: true },
+ },
+ async (input, extra) => {
+ const rateLimit = await limitMcpSearch(extra.requestInfo);
+ if (!rateLimit.success) {
+ return {
+ isError: true,
+ content: [
+ {
+ type: "text",
+ text: `Search rate limit exceeded. Try again in ${rateLimit.retryAfterSeconds} seconds.`,
+ },
+ ],
+ };
+ }
+
+ const { searchSiteArticles } = await import("@/lib/mcp/search");
+ const results = await searchSiteArticles(input);
+ return {
+ content: [
+ {
+ type: "text",
+ text:
+ results.length === 0
+ ? "No matching InvolutionHell articles were found."
+ : JSON.stringify(results, null, 2),
+ },
+ ],
+ structuredContent: { results },
+ };
+ },
+ );
+
+ server.registerTool(
+ "publish",
+ {
+ title: "Publish an InvolutionHell post",
+ description:
+ "Publish a lightweight Markdown post to the authenticated InvolutionHell account. Requires Authorization: Bearer on the MCP request.",
+ inputSchema: publishInputSchema,
+ outputSchema: publishOutputSchema,
+ annotations: { readOnlyHint: false, destructiveHint: false },
+ },
+ async (input, extra) => {
+ const identity = getVerifiedIdentity(extra.authInfo);
+ if (!identity) {
+ return {
+ isError: true,
+ content: [
+ {
+ type: "text",
+ text: "Authentication required. Sign in to InvolutionHell, obtain a satoken, and reconnect with Authorization: Bearer .",
+ },
+ ],
+ };
+ }
+
+ try {
+ const published = await publishPost(input, identity.backendHeaders);
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Published “${published.title}”: ${published.url}`,
+ },
+ ],
+ structuredContent: published,
+ };
+ } catch (error) {
+ return {
+ isError: true,
+ content: [
+ {
+ type: "text",
+ text:
+ error instanceof PublishError
+ ? error.message
+ : "Publishing failed unexpectedly. Try again later.",
+ },
+ ],
+ };
+ }
+ },
+ );
+}
diff --git a/package.json b/package.json
index 32bd2568..c64a3f5b 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"@giscus/react": "^3.1.0",
"@milkdown/crepe": "^7.17.1",
"@milkdown/kit": "^7.17.1",
+ "@modelcontextprotocol/sdk": "1.26.0",
"@orama/orama": "^3.1.14",
"@orama/tokenizers": "^3.1.14",
"@prisma/adapter-pg": "^7.4.1",
@@ -66,6 +67,7 @@
"fumadocs-mdx": "^11.10.1",
"fumadocs-ui": "15.7.13",
"lucide-react": "^0.544.0",
+ "mcp-handler": "^1.1.0",
"motion": "^12.23.16",
"next": "16.2.6",
"next-auth": "5.0.0-beta.30",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5fbeeb4d..d20efb2a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -51,6 +51,9 @@ importers:
'@milkdown/kit':
specifier: ^7.17.1
version: 7.20.0(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.0)(typescript@5.9.3)
+ '@modelcontextprotocol/sdk':
+ specifier: 1.26.0
+ version: 1.26.0(zod@4.3.6)
'@orama/orama':
specifier: ^3.1.14
version: 3.1.18
@@ -141,6 +144,9 @@ importers:
lucide-react:
specifier: ^0.544.0
version: 0.544.0(react@19.2.3)
+ mcp-handler:
+ specifier: ^1.1.0
+ version: 1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
motion:
specifier: ^12.23.16
version: 12.38.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -1725,6 +1731,16 @@ packages:
'@milkdown/utils@7.20.0':
resolution: {integrity: sha512-ciEhtLKhIW/Kaz/NRE5DUXVoMCdenn7S4mClrO7sZ/nXtmObnk3okJzSDnamQoDOcLOIbpOu1V3E1Btkvc5x9w==}
+ '@modelcontextprotocol/sdk@1.26.0':
+ resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@cfworker/json-schema': ^4.1.1
+ zod: ^3.25 || ^4.0
+ peerDependenciesMeta:
+ '@cfworker/json-schema':
+ optional: true
+
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -2980,6 +2996,35 @@ packages:
react: '>=16.9.0'
react-dom: '>=16.9.0'
+ '@redis/bloom@1.2.0':
+ resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==}
+ peerDependencies:
+ '@redis/client': ^1.0.0
+
+ '@redis/client@1.6.1':
+ resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==}
+ engines: {node: '>=14'}
+
+ '@redis/graph@1.1.1':
+ resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==}
+ peerDependencies:
+ '@redis/client': ^1.0.0
+
+ '@redis/json@1.0.7':
+ resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==}
+ peerDependencies:
+ '@redis/client': ^1.0.0
+
+ '@redis/search@1.2.0':
+ resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==}
+ peerDependencies:
+ '@redis/client': ^1.0.0
+
+ '@redis/time-series@1.1.0':
+ resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==}
+ peerDependencies:
+ '@redis/client': ^1.0.0
+
'@rolldown/binding-android-arm64@1.0.0-beta.35':
resolution: {integrity: sha512-zVTg0544Ib1ldJSWwjy8URWYHlLFJ98rLnj+2FIj5fRs4KqGKP4VgH/pVUbXNGxeLFjItie6NSK1Un7nJixneQ==}
cpu: [arm64]
@@ -4412,6 +4457,10 @@ packages:
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
engines: {node: ^18.17.0 || >=20.5.0}
+ accepts@2.0.0:
+ resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
+ engines: {node: '>= 0.6'}
+
acorn-import-attributes@1.9.5:
resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==}
peerDependencies:
@@ -4459,6 +4508,14 @@ packages:
ajv:
optional: true
+ ajv-formats@3.0.1:
+ resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+
ajv-keywords@5.1.0:
resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==}
peerDependencies:
@@ -4636,6 +4693,10 @@ packages:
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
+ body-parser@2.3.0:
+ resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
+ engines: {node: '>=18'}
+
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
@@ -4665,6 +4726,10 @@ packages:
resolution: {integrity: sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==}
engines: {node: '>= 0.8'}
+ bytes@3.1.2:
+ resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+ engines: {node: '>= 0.8'}
+
c12@3.1.0:
resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==}
peerDependencies:
@@ -4703,6 +4768,10 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
+ chalk@5.6.2:
+ resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
+ engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
+
character-entities-html4@2.1.0:
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
@@ -4772,6 +4841,10 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
+ cluster-key-slot@1.1.2:
+ resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
+ engines: {node: '>=0.10.0'}
+
cmdk@1.1.1:
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
peerDependencies:
@@ -4800,6 +4873,10 @@ packages:
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+ commander@11.1.0:
+ resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
+ engines: {node: '>=16'}
+
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
@@ -4827,10 +4904,22 @@ packages:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
+ content-disposition@1.1.0:
+ resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
+ engines: {node: '>=18'}
+
content-type@1.0.4:
resolution: {integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==}
engines: {node: '>= 0.6'}
+ content-type@1.0.5:
+ resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+ engines: {node: '>= 0.6'}
+
+ content-type@2.0.0:
+ resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
+ engines: {node: '>=18'}
+
convert-hrtime@3.0.0:
resolution: {integrity: sha512-7V+KqSvMiHp8yWDuwfww06XleMWVVB9b9tURBx+G7UTADuo5hYPuowKloz4OzOqbPezxgo+fdQ1522WzPG4OeA==}
engines: {node: '>=8'}
@@ -4841,9 +4930,21 @@ packages:
cookie-es@2.0.1:
resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==}
+ cookie-signature@1.2.2:
+ resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
+ engines: {node: '>=6.6.0'}
+
+ cookie@0.7.2:
+ resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+ engines: {node: '>= 0.6'}
+
copy-to-clipboard@3.3.3:
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
+ cors@2.8.6:
+ resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
+ engines: {node: '>= 0.10'}
+
create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
@@ -4935,6 +5036,10 @@ packages:
resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
engines: {node: '>= 0.6'}
+ depd@2.0.0:
+ resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+ engines: {node: '>= 0.8'}
+
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -4980,6 +5085,9 @@ packages:
engines: {node: '>=16'}
hasBin: true
+ ee-first@1.1.1:
+ resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+
effect@3.20.0:
resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==}
@@ -4996,6 +5104,10 @@ packages:
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
engines: {node: '>=14'}
+ encodeurl@2.0.0:
+ resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
+ engines: {node: '>= 0.8'}
+
end-of-stream@1.1.0:
resolution: {integrity: sha512-EoulkdKF/1xa92q25PbjuDcgJ9RDHYU2Rs3SCIvs2/dSQ3BpmxneNHmA/M7fe60M3PrV7nNGTTNbkK62l6vXiQ==}
@@ -5210,6 +5322,9 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
+ escape-html@1.0.3:
+ resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
@@ -5405,6 +5520,10 @@ packages:
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
engines: {node: '>=18.0.0'}
+ eventsource@3.0.7:
+ resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
+ engines: {node: '>=18.0.0'}
+
execa@3.2.0:
resolution: {integrity: sha512-kJJfVbI/lZE1PZYDI5VPxp8zXPO9rtxOkhpZ0jMKha56AI9y2gGVC6bkukStQf0ka5Rh15BA5m7cCCH4jmHqkw==}
engines: {node: ^8.12.0 || >=9.7.0}
@@ -5417,6 +5536,16 @@ packages:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
+ express-rate-limit@8.5.2:
+ resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==}
+ engines: {node: '>= 16'}
+ peerDependencies:
+ express: '>= 4.11'
+
+ express@5.2.1:
+ resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
+ engines: {node: '>= 18'}
+
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
@@ -5484,6 +5613,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
+ finalhandler@2.1.1:
+ resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
+ engines: {node: '>= 18.0.0'}
+
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -5506,6 +5639,10 @@ packages:
forwarded-parse@2.1.2:
resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==}
+ forwarded@0.2.0:
+ resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
+ engines: {node: '>= 0.6'}
+
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
@@ -5523,6 +5660,10 @@ packages:
react-dom:
optional: true
+ fresh@2.0.0:
+ resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
+ engines: {node: '>= 0.8'}
+
fs-extra@11.1.0:
resolution: {integrity: sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==}
engines: {node: '>=14.14'}
@@ -5627,6 +5768,10 @@ packages:
resolution: {integrity: sha512-H7cUpwCQSiJmAHM4c/aFu6fUfrhWXW1ncyh8ftxEPMu6AiYkHw9K8br720TGPZJbk5eOH2bynjZD1yPvdDAmag==}
engines: {node: '>= 4'}
+ generic-pool@3.9.0:
+ resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==}
+ engines: {node: '>= 4'}
+
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
@@ -5819,6 +5964,10 @@ packages:
resolution: {integrity: sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==}
engines: {node: '>= 0.6'}
+ http-errors@2.0.1:
+ resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
+ engines: {node: '>= 0.8'}
+
http-status-codes@2.3.0:
resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
@@ -5898,6 +6047,14 @@ packages:
intl-messageformat@11.2.4:
resolution: {integrity: sha512-iKP6+uJXn+XcfRgYfGPE3+mqCoODV2vATrXDLo/YkYgIdelJHJPBEbc0GZThipAYPuk+8QJFiPgOfblU085ABg==}
+ ip-address@10.2.0:
+ resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
+ engines: {node: '>= 12'}
+
+ ipaddr.js@1.9.1:
+ resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+ engines: {node: '>= 0.10'}
+
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
@@ -5996,6 +6153,9 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
+ is-promise@4.0.0:
+ resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
+
is-property@1.0.2:
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
@@ -6097,6 +6257,9 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+ json-schema-typed@8.0.2:
+ resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
+
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
@@ -6297,6 +6460,16 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ mcp-handler@1.1.0:
+ resolution: {integrity: sha512-MVCES7g18gcoZy+R/3v5nadkUMzMAWdos8jRl6DyljOKvd2/ZKDmwlCjL6zp4vo+7FeCXOYL1uWinHWlkKAAUg==}
+ hasBin: true
+ peerDependencies:
+ '@modelcontextprotocol/sdk': 1.26.0
+ next: '>=13.0.0'
+ peerDependenciesMeta:
+ next:
+ optional: true
+
mdast-util-definitions@6.0.0:
resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==}
@@ -6351,6 +6524,14 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+ media-typer@1.1.0:
+ resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
+ engines: {node: '>= 0.8'}
+
+ merge-descriptors@2.0.0:
+ resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
+ engines: {node: '>=18'}
+
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -6487,6 +6668,10 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
+ mime-types@3.0.2:
+ resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
+ engines: {node: '>=18'}
+
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
@@ -6763,6 +6948,10 @@ packages:
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
+ on-finished@2.4.1:
+ resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+ engines: {node: '>= 0.8'}
+
once@1.3.3:
resolution: {integrity: sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==}
@@ -6824,6 +7013,10 @@ packages:
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
+ parseurl@1.3.3:
+ resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+ engines: {node: '>= 0.8'}
+
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
@@ -6922,6 +7115,10 @@ packages:
pinyin-pro@3.28.0:
resolution: {integrity: sha512-mMRty6RisoyYNphJrTo3pnvp3w8OMZBrXm9YSWkxhAfxKj1KZk2y8T2PDIZlDDRsvZ0No+Hz6FI4sZpA6Ey25g==}
+ pkce-challenge@5.0.1:
+ resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
+ engines: {node: '>=16.20.0'}
+
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
@@ -7080,6 +7277,10 @@ packages:
prosemirror-view:
optional: true
+ proxy-addr@2.0.7:
+ resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
+ engines: {node: '>= 0.10'}
+
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
@@ -7093,6 +7294,10 @@ packages:
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
+ qs@6.15.3:
+ resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
+ engines: {node: '>=0.6'}
+
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -7109,10 +7314,18 @@ packages:
'@types/react-dom':
optional: true
+ range-parser@1.3.0:
+ resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
+ engines: {node: '>= 0.6'}
+
raw-body@2.4.1:
resolution: {integrity: sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==}
engines: {node: '>= 0.8'}
+ raw-body@3.0.2:
+ resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
+ engines: {node: '>= 0.10'}
+
rc-cascader@3.34.0:
resolution: {integrity: sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==}
peerDependencies:
@@ -7425,6 +7638,9 @@ packages:
recma-stringify@1.0.0:
resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==}
+ redis@4.7.1:
+ resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==}
+
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -7547,6 +7763,10 @@ packages:
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
+ router@2.2.0:
+ resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
+ engines: {node: '>= 18'}
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -7596,9 +7816,17 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ send@1.2.1:
+ resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
+ engines: {node: '>= 18'}
+
seq-queue@0.0.5:
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
+ serve-static@2.2.1:
+ resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
+ engines: {node: '>= 18'}
+
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -7614,6 +7842,9 @@ packages:
setprototypeof@1.1.1:
resolution: {integrity: sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==}
+ setprototypeof@1.2.0:
+ resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -7645,6 +7876,10 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
+ side-channel@1.1.1:
+ resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
+ engines: {node: '>= 0.4'}
+
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -7718,6 +7953,10 @@ packages:
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
engines: {node: '>= 0.6'}
+ statuses@2.0.2:
+ resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+ engines: {node: '>= 0.8'}
+
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
@@ -7948,6 +8187,10 @@ packages:
resolution: {integrity: sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==}
engines: {node: '>=0.6'}
+ toidentifier@1.0.1:
+ resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+ engines: {node: '>=0.6'}
+
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
@@ -8014,6 +8257,10 @@ packages:
resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==}
engines: {node: '>=8'}
+ type-is@2.1.0:
+ resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
+ engines: {node: '>= 18'}
+
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -8194,6 +8441,10 @@ packages:
typescript:
optional: true
+ vary@1.1.2:
+ resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+ engines: {node: '>= 0.8'}
+
vercel@48.12.1:
resolution: {integrity: sha512-+lMj+qIXI/Iy7UXKu1wpFCwCaeV1lmrUdBbYQWXBM1/9XsX8vUfohHLkPrPSam8tDyVghKmaYu1ZD5uuHgo5uw==}
engines: {node: '>= 18'}
@@ -8420,6 +8671,11 @@ packages:
zeptomatch@2.1.0:
resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==}
+ zod-to-json-schema@3.25.2:
+ resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
+ peerDependencies:
+ zod: ^3.25.28 || ^4
+
zod-validation-error@4.0.2:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'}
@@ -10411,6 +10667,28 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)':
+ dependencies:
+ '@hono/node-server': 1.19.11(hono@4.12.18)
+ ajv: 8.20.0
+ ajv-formats: 3.0.1(ajv@8.20.0)
+ content-type: 1.0.5
+ cors: 2.8.6
+ cross-spawn: 7.0.6
+ eventsource: 3.0.7
+ eventsource-parser: 3.0.6
+ express: 5.2.1
+ express-rate-limit: 8.5.2(express@5.2.1)
+ hono: 4.12.18
+ jose: 6.2.2
+ json-schema-typed: 8.0.2
+ pkce-challenge: 5.0.1
+ raw-body: 3.0.2
+ zod: 4.3.6
+ zod-to-json-schema: 3.25.2(zod@4.3.6)
+ transitivePeerDependencies:
+ - supports-color
+
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.9.2
@@ -11765,6 +12043,32 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
+ '@redis/bloom@1.2.0(@redis/client@1.6.1)':
+ dependencies:
+ '@redis/client': 1.6.1
+
+ '@redis/client@1.6.1':
+ dependencies:
+ cluster-key-slot: 1.1.2
+ generic-pool: 3.9.0
+ yallist: 4.0.0
+
+ '@redis/graph@1.1.1(@redis/client@1.6.1)':
+ dependencies:
+ '@redis/client': 1.6.1
+
+ '@redis/json@1.0.7(@redis/client@1.6.1)':
+ dependencies:
+ '@redis/client': 1.6.1
+
+ '@redis/search@1.2.0(@redis/client@1.6.1)':
+ dependencies:
+ '@redis/client': 1.6.1
+
+ '@redis/time-series@1.1.0(@redis/client@1.6.1)':
+ dependencies:
+ '@redis/client': 1.6.1
+
'@rolldown/binding-android-arm64@1.0.0-beta.35':
optional: true
@@ -13450,6 +13754,11 @@ snapshots:
abbrev@3.0.1: {}
+ accepts@2.0.0:
+ dependencies:
+ mime-types: 3.0.2
+ negotiator: 1.0.0
+
acorn-import-attributes@1.9.5(acorn@8.16.0):
dependencies:
acorn: 8.16.0
@@ -13488,6 +13797,10 @@ snapshots:
optionalDependencies:
ajv: 8.20.0
+ ajv-formats@3.0.1(ajv@8.20.0):
+ optionalDependencies:
+ ajv: 8.20.0
+
ajv-keywords@5.1.0(ajv@8.20.0):
dependencies:
ajv: 8.20.0
@@ -13734,6 +14047,20 @@ snapshots:
dependencies:
file-uri-to-path: 1.0.0
+ body-parser@2.3.0:
+ dependencies:
+ bytes: 3.1.2
+ content-type: 2.0.0
+ debug: 4.4.3
+ http-errors: 2.0.1
+ iconv-lite: 0.7.2
+ on-finished: 2.4.1
+ qs: 6.15.3
+ raw-body: 3.0.2
+ type-is: 2.1.0
+ transitivePeerDependencies:
+ - supports-color
+
bowser@2.14.1: {}
brace-expansion@1.1.13:
@@ -13763,6 +14090,8 @@ snapshots:
bytes@3.1.0: {}
+ bytes@3.1.2: {}
+
c12@3.1.0:
dependencies:
chokidar: 4.0.3
@@ -13808,6 +14137,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
+ chalk@5.6.2: {}
+
character-entities-html4@2.1.0: {}
character-entities-legacy@3.0.0: {}
@@ -13863,6 +14194,8 @@ snapshots:
clsx@2.1.1: {}
+ cluster-key-slot@1.1.2: {}
+
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
@@ -13899,6 +14232,8 @@ snapshots:
comma-separated-tokens@2.0.3: {}
+ commander@11.1.0: {}
+
commander@14.0.3: {}
commander@2.20.3: {}
@@ -13915,18 +14250,33 @@ snapshots:
consola@3.4.2: {}
+ content-disposition@1.1.0: {}
+
content-type@1.0.4: {}
+ content-type@1.0.5: {}
+
+ content-type@2.0.0: {}
+
convert-hrtime@3.0.0: {}
convert-source-map@2.0.0: {}
cookie-es@2.0.1: {}
+ cookie-signature@1.2.2: {}
+
+ cookie@0.7.2: {}
+
copy-to-clipboard@3.3.3:
dependencies:
toggle-selection: 1.0.6
+ cors@2.8.6:
+ dependencies:
+ object-assign: 4.1.1
+ vary: 1.1.2
+
create-require@1.1.1: {}
crelt@1.0.6: {}
@@ -14001,6 +14351,8 @@ snapshots:
depd@1.1.2: {}
+ depd@2.0.0: {}
+
dequal@2.0.3: {}
destr@2.0.5: {}
@@ -14045,6 +14397,8 @@ snapshots:
signal-exit: 4.0.2
time-span: 4.0.0
+ ee-first@1.1.1: {}
+
effect@3.20.0:
dependencies:
'@standard-schema/spec': 1.1.0
@@ -14058,6 +14412,8 @@ snapshots:
empathic@2.0.0: {}
+ encodeurl@2.0.0: {}
+
end-of-stream@1.1.0:
dependencies:
once: 1.3.3
@@ -14369,6 +14725,8 @@ snapshots:
escalade@3.2.0: {}
+ escape-html@1.0.3: {}
+
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
@@ -14642,6 +15000,10 @@ snapshots:
eventsource-parser@3.0.6: {}
+ eventsource@3.0.7:
+ dependencies:
+ eventsource-parser: 3.0.6
+
execa@3.2.0:
dependencies:
cross-spawn: 7.0.6
@@ -14669,6 +15031,44 @@ snapshots:
expect-type@1.3.0: {}
+ express-rate-limit@8.5.2(express@5.2.1):
+ dependencies:
+ express: 5.2.1
+ ip-address: 10.2.0
+
+ express@5.2.1:
+ dependencies:
+ accepts: 2.0.0
+ body-parser: 2.3.0
+ content-disposition: 1.1.0
+ content-type: 1.0.5
+ cookie: 0.7.2
+ cookie-signature: 1.2.2
+ debug: 4.4.3
+ depd: 2.0.0
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ finalhandler: 2.1.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ merge-descriptors: 2.0.0
+ mime-types: 3.0.2
+ on-finished: 2.4.1
+ once: 1.4.0
+ parseurl: 1.3.3
+ proxy-addr: 2.0.7
+ qs: 6.15.3
+ range-parser: 1.3.0
+ router: 2.2.0
+ send: 1.2.1
+ serve-static: 2.2.1
+ statuses: 2.0.2
+ type-is: 2.1.0
+ vary: 1.1.2
+ transitivePeerDependencies:
+ - supports-color
+
exsolve@1.0.8: {}
extend-shallow@2.0.1:
@@ -14738,6 +15138,17 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
+ finalhandler@2.1.1:
+ dependencies:
+ debug: 4.4.3
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ on-finished: 2.4.1
+ parseurl: 1.3.3
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -14761,6 +15172,8 @@ snapshots:
forwarded-parse@2.1.2: {}
+ forwarded@0.2.0: {}
+
fraction.js@5.3.4: {}
framer-motion@12.38.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
@@ -14772,6 +15185,8 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
+ fresh@2.0.0: {}
+
fs-extra@11.1.0:
dependencies:
graceful-fs: 4.2.11
@@ -14894,6 +15309,8 @@ snapshots:
generic-pool@3.4.2: {}
+ generic-pool@3.9.0: {}
+
gensync@1.0.0-beta.2: {}
get-east-asian-width@1.5.0: {}
@@ -15169,6 +15586,14 @@ snapshots:
statuses: 1.5.0
toidentifier: 1.0.0
+ http-errors@2.0.1:
+ dependencies:
+ depd: 2.0.0
+ inherits: 2.0.4
+ setprototypeof: 1.2.0
+ statuses: 2.0.2
+ toidentifier: 1.0.1
+
http-status-codes@2.3.0: {}
https-proxy-agent@5.0.1:
@@ -15247,6 +15672,10 @@ snapshots:
'@formatjs/fast-memoize': 3.1.4
'@formatjs/icu-messageformat-parser': 3.5.7
+ ip-address@10.2.0: {}
+
+ ipaddr.js@1.9.1: {}
+
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
@@ -15343,6 +15772,8 @@ snapshots:
is-plain-obj@4.1.0: {}
+ is-promise@4.0.0: {}
+
is-property@1.0.2: {}
is-reference@1.2.1:
@@ -15441,6 +15872,8 @@ snapshots:
json-schema-traverse@1.0.0: {}
+ json-schema-typed@8.0.2: {}
+
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
@@ -15626,6 +16059,15 @@ snapshots:
math-intrinsics@1.1.0: {}
+ mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)):
+ dependencies:
+ '@modelcontextprotocol/sdk': 1.26.0(zod@4.3.6)
+ chalk: 5.6.2
+ commander: 11.1.0
+ redis: 4.7.1
+ optionalDependencies:
+ next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+
mdast-util-definitions@6.0.0:
dependencies:
'@types/mdast': 4.0.4
@@ -15807,6 +16249,10 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
+ media-typer@1.1.0: {}
+
+ merge-descriptors@2.0.0: {}
+
merge-stream@2.0.0: {}
merge2@1.4.1: {}
@@ -16104,6 +16550,10 @@ snapshots:
dependencies:
mime-db: 1.52.0
+ mime-types@3.0.2:
+ dependencies:
+ mime-db: 1.54.0
+
mimic-fn@2.1.0: {}
mimic-function@5.0.1: {}
@@ -16337,6 +16787,10 @@ snapshots:
ohash@2.0.11: {}
+ on-finished@2.4.1:
+ dependencies:
+ ee-first: 1.1.1
+
once@1.3.3:
dependencies:
wrappy: 1.0.2
@@ -16410,6 +16864,8 @@ snapshots:
dependencies:
entities: 6.0.1
+ parseurl@1.3.3: {}
+
path-browserify@1.0.1: {}
path-exists@4.0.0: {}
@@ -16491,6 +16947,8 @@ snapshots:
pinyin-pro@3.28.0: {}
+ pkce-challenge@5.0.1: {}
+
pkg-types@2.3.0:
dependencies:
confbox: 0.2.4
@@ -16675,6 +17133,11 @@ snapshots:
prosemirror-state: 1.4.4
prosemirror-view: 1.41.8
+ proxy-addr@2.0.7:
+ dependencies:
+ forwarded: 0.2.0
+ ipaddr.js: 1.9.1
+
proxy-from-env@1.1.0: {}
pump@3.0.4:
@@ -16686,6 +17149,11 @@ snapshots:
pure-rand@6.1.0: {}
+ qs@6.15.3:
+ dependencies:
+ es-define-property: 1.0.1
+ side-channel: 1.1.1
+
queue-microtask@1.2.3: {}
radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
@@ -16751,6 +17219,8 @@ snapshots:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
+ range-parser@1.3.0: {}
+
raw-body@2.4.1:
dependencies:
bytes: 3.1.0
@@ -16758,6 +17228,13 @@ snapshots:
iconv-lite: 0.4.24
unpipe: 1.0.0
+ raw-body@3.0.2:
+ dependencies:
+ bytes: 3.1.2
+ http-errors: 2.0.1
+ iconv-lite: 0.7.2
+ unpipe: 1.0.0
+
rc-cascader@3.34.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@babel/runtime': 7.29.2
@@ -17183,6 +17660,15 @@ snapshots:
unified: 11.0.5
vfile: 6.0.3
+ redis@4.7.1:
+ dependencies:
+ '@redis/bloom': 1.2.0(@redis/client@1.6.1)
+ '@redis/client': 1.6.1
+ '@redis/graph': 1.1.1(@redis/client@1.6.1)
+ '@redis/json': 1.0.7(@redis/client@1.6.1)
+ '@redis/search': 1.2.0(@redis/client@1.6.1)
+ '@redis/time-series': 1.1.0(@redis/client@1.6.1)
+
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.9
@@ -17448,6 +17934,16 @@ snapshots:
rope-sequence@1.3.4: {}
+ router@2.2.0:
+ dependencies:
+ debug: 4.4.3
+ depd: 2.0.0
+ is-promise: 4.0.0
+ parseurl: 1.3.3
+ path-to-regexp: 8.3.0
+ transitivePeerDependencies:
+ - supports-color
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -17501,8 +17997,33 @@ snapshots:
semver@7.8.0: {}
+ send@1.2.1:
+ dependencies:
+ debug: 4.4.3
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ mime-types: 3.0.2
+ ms: 2.1.3
+ on-finished: 2.4.1
+ range-parser: 1.3.0
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
seq-queue@0.0.5: {}
+ serve-static@2.2.1:
+ dependencies:
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ parseurl: 1.3.3
+ send: 1.2.1
+ transitivePeerDependencies:
+ - supports-color
+
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -17527,6 +18048,8 @@ snapshots:
setprototypeof@1.1.1: {}
+ setprototypeof@1.2.0: {}
+
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
@@ -17604,6 +18127,14 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
+ side-channel@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-list: 1.0.1
+ side-channel-map: 1.0.1
+ side-channel-weakmap: 1.0.2
+
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
@@ -17657,6 +18188,8 @@ snapshots:
statuses@1.5.0: {}
+ statuses@2.0.2: {}
+
std-env@3.10.0: {}
std-env@4.0.0: {}
@@ -17866,6 +18399,8 @@ snapshots:
toidentifier@1.0.0: {}
+ toidentifier@1.0.1: {}
+
tr46@0.0.3: {}
tree-kill@1.2.2: {}
@@ -17936,6 +18471,12 @@ snapshots:
type-fest@0.7.1: {}
+ type-is@2.1.0:
+ dependencies:
+ content-type: 2.0.0
+ media-typer: 1.1.0
+ mime-types: 3.0.2
+
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -18150,6 +18691,8 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
+ vary@1.1.2: {}
+
vercel@48.12.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33)(rollup@4.60.1)(typescript@5.9.3):
dependencies:
'@vercel/backends': 0.0.14(@emnapi/core@1.9.2)(@emnapi/runtime@1.10.0)(rollup@4.60.1)(typescript@5.9.3)
@@ -18419,6 +18962,10 @@ snapshots:
grammex: 3.1.12
graphmatch: 1.1.1
+ zod-to-json-schema@3.25.2(zod@4.3.6):
+ dependencies:
+ zod: 4.3.6
+
zod-validation-error@4.0.2(zod@4.3.6):
dependencies:
zod: 4.3.6
diff --git a/tests/mcp-auth.test.ts b/tests/mcp-auth.test.ts
new file mode 100644
index 00000000..3a152477
--- /dev/null
+++ b/tests/mcp-auth.test.ts
@@ -0,0 +1,155 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { protectMcpHandler, verifyToken } from "@/lib/mcp/auth";
+
+const request = new Request("https://involutionhell.com/api/mcp");
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ delete process.env.BACKEND_URL;
+});
+
+describe("MCP token verification", () => {
+ it("validates a satoken and returns MCP auth info", async () => {
+ process.env.BACKEND_URL = "https://backend.example";
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ Response.json({ data: { id: 42, username: "alice" } }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ const authInfo = await verifyToken(request, "secret-token");
+
+ expect(fetchMock).toHaveBeenCalledWith("https://backend.example/auth/me", {
+ headers: { satoken: "secret-token" },
+ signal: expect.any(AbortSignal),
+ });
+ expect(authInfo).toMatchObject({
+ clientId: "involutionhell-user:42",
+ scopes: ["publish"],
+ extra: { user: { id: 42, username: "alice" } },
+ });
+ });
+
+ it.each([401, 403])("returns undefined for a backend %i", async (status) => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(new Response(null, { status })),
+ );
+
+ await expect(
+ verifyToken(request, "expired-token"),
+ ).resolves.toBeUndefined();
+ });
+
+ it("propagates a network failure to the auth wrapper", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockRejectedValue(new Error("network unavailable")),
+ );
+
+ await expect(verifyToken(request, "secret-token")).rejects.toThrow(
+ "network unavailable",
+ );
+ });
+
+ it("does not call the backend when no bearer token is present", async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+
+ await expect(verifyToken(request)).resolves.toBeUndefined();
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("does not verify bearer tokens for non-publish requests", async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+ const handler = vi.fn(() => Response.json({ ok: true }));
+ const protectedHandler = protectMcpHandler(handler);
+ const bearerRequest = new Request(request, {
+ method: "POST",
+ headers: { authorization: "Bearer bogus" },
+ });
+
+ const response = await protectedHandler(bearerRequest, {
+ jsonrpc: "2.0",
+ id: 1,
+ method: "tools/list",
+ });
+
+ expect(response.status).toBe(200);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("uses the same 401 challenge for missing and invalid publish tokens", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi
+ .fn()
+ .mockResolvedValue(new Response(null, { status: 401 })),
+ );
+ const protectedHandler = protectMcpHandler(() =>
+ Response.json({ ok: true }),
+ );
+ const body = {
+ jsonrpc: "2.0",
+ id: 1,
+ method: "tools/call",
+ params: { name: "publish", arguments: {} },
+ };
+ const missing = await protectedHandler(
+ new Request(request, { method: "POST" }),
+ body,
+ );
+ const invalid = await protectedHandler(
+ new Request(request, {
+ method: "POST",
+ headers: { authorization: "Bearer bogus" },
+ }),
+ body,
+ );
+
+ expect(invalid.status).toBe(401);
+ expect(invalid.headers.get("www-authenticate")).toBe(
+ missing.headers.get("www-authenticate"),
+ );
+ });
+
+ it.each([
+ [
+ "HTTP 500",
+ () => Promise.resolve(new Response("backend secret", { status: 500 })),
+ ],
+ [
+ "timeout",
+ () => Promise.reject(new DOMException("timed out", "TimeoutError")),
+ ],
+ ])(
+ "returns 503 without backend details on auth %s",
+ async (_case, fetchResult) => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockImplementation(fetchResult),
+ );
+ const protectedHandler = protectMcpHandler(() =>
+ Response.json({ ok: true }),
+ );
+ const response = await protectedHandler(
+ new Request(request, {
+ method: "POST",
+ headers: { authorization: "Bearer token" },
+ }),
+ {
+ method: "tools/call",
+ params: { name: "publish" },
+ },
+ );
+ const text = await response.text();
+
+ expect(response.status).toBe(503);
+ expect(text).toContain("Authentication service unavailable");
+ expect(text).not.toContain("backend secret");
+ expect(text).not.toContain("timed out");
+ },
+ );
+});
diff --git a/tests/mcp-promise-cache.test.ts b/tests/mcp-promise-cache.test.ts
new file mode 100644
index 00000000..04685e0d
--- /dev/null
+++ b/tests/mcp-promise-cache.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it, vi } from "vitest";
+import { createRetryablePromiseCache } from "@/lib/mcp/promise-cache";
+
+describe("MCP shard promise cache", () => {
+ it("shares in-flight work and retries after a rejected build", async () => {
+ const builder = vi
+ .fn<(locale: "zh" | "en") => Promise>()
+ .mockRejectedValueOnce(new Error("build failed"))
+ .mockResolvedValue("ready");
+ const getCached = createRetryablePromiseCache(builder);
+
+ const first = getCached("zh");
+ expect(getCached("zh")).toBe(first);
+ await expect(first).rejects.toThrow("build failed");
+ await expect(getCached("zh")).resolves.toBe("ready");
+ expect(builder).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/tests/mcp-publish.test.ts b/tests/mcp-publish.test.ts
new file mode 100644
index 00000000..d6fef14c
--- /dev/null
+++ b/tests/mcp-publish.test.ts
@@ -0,0 +1,99 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { PublishError, publishPost } from "@/lib/mcp/publish";
+
+const input = {
+ title: "A lightweight post",
+ content_md: "# Hello\n\nBody",
+ description: "Short description",
+ tags: ["career", "community"],
+ slug: "lightweight-post",
+};
+
+afterEach(() => {
+ delete process.env.BACKEND_URL;
+});
+
+describe("MCP publish", () => {
+ it("forwards the satoken and backend post body", async () => {
+ process.env.BACKEND_URL = "https://backend.example";
+ const fetchMock = vi.fn().mockResolvedValue(
+ Response.json(
+ {
+ success: true,
+ data: {
+ title: "A lightweight post",
+ slug: "lightweight-post",
+ authorUsername: "alice",
+ },
+ },
+ { status: 201 },
+ ),
+ );
+
+ const result = await publishPost(
+ input,
+ { satoken: "secret-token" },
+ fetchMock,
+ );
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ "https://backend.example/api/posts",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ satoken: "secret-token",
+ },
+ body: JSON.stringify({
+ title: "A lightweight post",
+ contentMd: "# Hello\n\nBody",
+ description: "Short description",
+ tags: ["career", "community"],
+ slug: "lightweight-post",
+ }),
+ signal: expect.any(AbortSignal),
+ },
+ );
+ expect(result).toEqual({
+ title: "A lightweight post",
+ slug: "lightweight-post",
+ url: "https://involutionhell.com/u/alice/posts/lightweight-post",
+ });
+ });
+
+ it.each([
+ [401, "token is expired or invalid"],
+ [409, "slug already exists"],
+ [500, "HTTP 500"],
+ ])("translates backend HTTP %i", async (status, message) => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(new Response(null, { status }));
+
+ await expect(
+ publishPost(input, { satoken: "secret-token" }, fetchMock),
+ ).rejects.toEqual(
+ expect.objectContaining>({
+ message: expect.stringContaining(message),
+ }),
+ );
+ });
+
+ it("turns a timed-out backend request into a retryable publish error", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockRejectedValue(new DOMException("timed out", "TimeoutError"));
+
+ await expect(
+ publishPost(input, { satoken: "secret-token" }, fetchMock),
+ ).rejects.toEqual(
+ expect.objectContaining>({
+ message: expect.stringContaining("Try again later"),
+ }),
+ );
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
+ );
+ });
+});
diff --git a/tests/mcp-request.test.ts b/tests/mcp-request.test.ts
new file mode 100644
index 00000000..c381d4d3
--- /dev/null
+++ b/tests/mcp-request.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it, vi } from "vitest";
+import { createMcpPostHandler } from "@/lib/mcp/request";
+
+function post(body: string, headers?: HeadersInit): Request {
+ return new Request("https://involutionhell.com/api/mcp", {
+ method: "POST",
+ headers: { "content-type": "application/json", ...headers },
+ body,
+ });
+}
+
+describe("MCP request boundary", () => {
+ it("returns a JSON-RPC parse error without invoking the handler", async () => {
+ const handler = vi.fn(() => Response.json({ ok: true }));
+ const response = await createMcpPostHandler(handler)(post("{oops"));
+
+ expect(response.status).toBe(400);
+ expect(await response.json()).toMatchObject({
+ jsonrpc: "2.0",
+ error: { code: -32700 },
+ id: null,
+ });
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it("rejects JSON-RPC batches", async () => {
+ const handler = vi.fn(() => Response.json({ ok: true }));
+ const response = await createMcpPostHandler(handler)(post("[]"));
+
+ expect(response.status).toBe(400);
+ expect(await response.json()).toMatchObject({
+ error: { code: -32600 },
+ });
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it("rejects request bodies larger than 1 MB", async () => {
+ const handler = vi.fn(() => Response.json({ ok: true }));
+ const response = await createMcpPostHandler(handler)(
+ post(JSON.stringify({ value: "x".repeat(1024 * 1024) })),
+ );
+
+ expect(response.status).toBe(413);
+ expect(await response.json()).toMatchObject({
+ error: { code: -32600 },
+ });
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it("passes parsed JSON and a reconstructed readable request", async () => {
+ const body = '{"jsonrpc":"2.0","id":1,"method":"tools/list"}';
+ const handler = vi.fn(async (request: Request, parsed: unknown) =>
+ Response.json({ text: await request.text(), parsed }),
+ );
+ const response = await createMcpPostHandler(handler)(post(body));
+
+ expect(await response.json()).toEqual({
+ text: body,
+ parsed: { jsonrpc: "2.0", id: 1, method: "tools/list" },
+ });
+ });
+});
diff --git a/tests/mcp-schemas.test.ts b/tests/mcp-schemas.test.ts
new file mode 100644
index 00000000..09e3c6a2
--- /dev/null
+++ b/tests/mcp-schemas.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+import { publishInputSchema, searchInputSchema } from "@/lib/mcp/schemas";
+
+describe("MCP tool input schemas", () => {
+ it("applies search defaults", () => {
+ expect(searchInputSchema.parse({ query: "数组" })).toEqual({
+ query: "数组",
+ locale: "zh",
+ limit: 8,
+ });
+ });
+
+ it.each([
+ { query: "" },
+ { query: " " },
+ { query: "array", locale: "fr" },
+ { query: "array", limit: 0 },
+ { query: "array", limit: 21 },
+ { query: "array", limit: 1.5 },
+ ])("rejects invalid search input %#", (input) => {
+ expect(searchInputSchema.safeParse(input).success).toBe(false);
+ });
+
+ it("accepts publish input and preserves Markdown whitespace", () => {
+ const content = " indented Markdown\n";
+ expect(
+ publishInputSchema.parse({ title: " Post ", content_md: content }),
+ ).toEqual({ title: "Post", content_md: content });
+ });
+
+ it.each([
+ { content_md: "body" },
+ { title: "", content_md: "body" },
+ { title: "Post", content_md: "" },
+ { title: "Post", content_md: " " },
+ { title: "Post", content_md: "body", tags: [""] },
+ { title: "Post", content_md: "body", slug: "" },
+ ])("rejects invalid publish input %#", (input) => {
+ expect(publishInputSchema.safeParse(input).success).toBe(false);
+ });
+
+ it.each([
+ { query: "q".repeat(201) },
+ { title: "t".repeat(201), content_md: "body" },
+ { title: "Post", content_md: "c".repeat(100_001) },
+ { title: "Post", content_md: "body", description: "d".repeat(501) },
+ {
+ title: "Post",
+ content_md: "body",
+ tags: Array.from({ length: 11 }, () => "tag"),
+ },
+ { title: "Post", content_md: "body", tags: ["t".repeat(51)] },
+ { title: "Post", content_md: "body", slug: "s".repeat(101) },
+ ])("rejects input just over a length limit %#", (input) => {
+ const schema = "query" in input ? searchInputSchema : publishInputSchema;
+ expect(schema.safeParse(input).success).toBe(false);
+ });
+});
diff --git a/tests/mcp-search-core.test.ts b/tests/mcp-search-core.test.ts
new file mode 100644
index 00000000..640aba05
--- /dev/null
+++ b/tests/mcp-search-core.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import type { AdvancedIndex } from "fumadocs-core/search/server";
+import {
+ createSearchShard,
+ extractSnippet,
+ searchShard,
+ toAbsoluteSiteUrl,
+} from "@/lib/mcp/search-core";
+
+const fixture: AdvancedIndex[] = [
+ {
+ id: "/docs/algorithms/array",
+ title: "数组算法入门",
+ description: "从数组的基本操作开始。",
+ url: "/docs/algorithms/array",
+ structuredData: {
+ headings: [{ id: "basics", content: "基础" }],
+ contents: [
+ {
+ heading: "basics",
+ content: `${"前置说明".repeat(45)}数组支持按下标访问元素,也常用于双指针算法。${"补充内容".repeat(45)}`,
+ },
+ ],
+ },
+ },
+ {
+ id: "/docs/backend/database",
+ title: "数据库基础",
+ description: "关系数据库与事务。",
+ url: "/docs/backend/database",
+ structuredData: {
+ headings: [],
+ contents: [{ heading: undefined, content: "事务需要满足 ACID。" }],
+ },
+ },
+];
+
+describe("MCP search core", () => {
+ it("maps fabricated index hits to article results", async () => {
+ const shard = await createSearchShard(fixture, "zh");
+ const results = await searchShard(shard, "数组", 1);
+
+ expect(results).toHaveLength(1);
+ expect(results[0]).toMatchObject({
+ title: "数组算法入门",
+ description: "从数组的基本操作开始。",
+ url: "https://involutionhell.com/docs/algorithms/array",
+ });
+ expect(results[0]?.snippet).toContain("数组");
+ expect(results[0]?.snippet.length).toBeLessThan(300);
+ });
+
+ it("precomputes normalized text without changing exact-match ranking", async () => {
+ const shard = await createSearchShard(fixture, "zh");
+ const indexed = shard.pages.get("/docs/algorithms/array");
+
+ expect(indexed?.normalized).toMatchObject({
+ title: "数组算法入门",
+ description: "从数组的基本操作开始。",
+ headings: ["基础"],
+ });
+ expect(indexed?.normalized.contents[0]).toContain("数组支持按下标访问元素");
+ expect((await searchShard(shard, "数组", 2))[0]?.title).toBe(
+ "数组算法入门",
+ );
+ });
+
+ it("extracts a bounded relevant snippet", () => {
+ const snippet = extractSnippet(fixture[0]!.structuredData.contents, "数组");
+ expect(snippet).toContain("数组");
+ expect(snippet.length).toBeLessThan(300);
+ });
+
+ it("builds absolute site URLs", () => {
+ expect(toAbsoluteSiteUrl("docs/example")).toBe(
+ "https://involutionhell.com/docs/example",
+ );
+ });
+});
diff --git a/tests/mcp-tools.test.ts b/tests/mcp-tools.test.ts
new file mode 100644
index 00000000..63c58107
--- /dev/null
+++ b/tests/mcp-tools.test.ts
@@ -0,0 +1,50 @@
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { describe, expect, it, vi } from "vitest";
+
+const searchState = vi.hoisted(() => ({
+ moduleLoads: 0,
+ search: vi.fn().mockResolvedValue([]),
+}));
+
+vi.mock("@/lib/mcp/rate-limit", () => ({
+ limitMcpSearch: vi.fn().mockResolvedValue({ success: true }),
+}));
+vi.mock("@/lib/mcp/search", () => {
+ searchState.moduleLoads += 1;
+ return { searchSiteArticles: searchState.search };
+});
+
+import { registerMcpTools } from "@/lib/mcp/tools";
+
+describe("MCP tool loading", () => {
+ it("loads the search implementation only when search is called", async () => {
+ const callbacks = new Map<
+ string,
+ (input: never, extra: never) => Promise
+ >();
+ const server = {
+ registerTool: vi.fn(
+ (
+ name: string,
+ _definition: unknown,
+ callback: (input: never, extra: never) => Promise,
+ ) => callbacks.set(name, callback),
+ ),
+ };
+
+ registerMcpTools(server as unknown as McpServer);
+ expect(searchState.moduleLoads).toBe(0);
+
+ await callbacks.get("search")!(
+ { query: "array", locale: "en", limit: 8 } as never,
+ { requestInfo: {} } as never,
+ );
+
+ expect(searchState.moduleLoads).toBe(1);
+ expect(searchState.search).toHaveBeenCalledWith({
+ query: "array",
+ locale: "en",
+ limit: 8,
+ });
+ });
+});
From 84096475446ba42a68bbf7166c5fa48d0f66bda8 Mon Sep 17 00:00:00 2001
From: Crokily
Date: Sun, 19 Jul 2026 10:17:54 +1000
Subject: [PATCH 3/6] feat(mcp): add connect page with per-client install
commands
/mcp (SSG, zh/en) lets users pick their MCP client and copy install
commands with their satoken auto-filled client-side after login. Covers
Claude Code, Codex CLI, OpenCode, Gemini CLI, Cursor, VS Code, the two
web clients (search-only until OAuth), and Pi (via the companion
skill/CLI since Pi has no MCP support). Command syntax per client was
verified against current official docs. The token is read from
localStorage only after mount and never appears in prerendered HTML.
Also documents the sa-token sliding-renewal option for the backend as a
near-term way to reduce 30-day token churn before OAuth.
---
app/[locale]/mcp/McpConnectClient.tsx | 256 +++++++++++++++++++
app/[locale]/mcp/page.tsx | 46 ++++
app/components/Header.tsx | 9 +
dev_docs/mcp_auth_upgrade.md | 6 +
dev_docs/mcp_server.md | 4 +
lib/mcp/connect-snippets.ts | 352 ++++++++++++++++++++++++++
lib/use-auth.tsx | 2 +-
messages/en.json | 50 ++++
messages/zh.json | 50 ++++
tests/mcp-connect-snippets.test.ts | 137 ++++++++++
10 files changed, 911 insertions(+), 1 deletion(-)
create mode 100644 app/[locale]/mcp/McpConnectClient.tsx
create mode 100644 app/[locale]/mcp/page.tsx
create mode 100644 lib/mcp/connect-snippets.ts
create mode 100644 tests/mcp-connect-snippets.test.ts
diff --git a/app/[locale]/mcp/McpConnectClient.tsx b/app/[locale]/mcp/McpConnectClient.tsx
new file mode 100644
index 00000000..7923a0e5
--- /dev/null
+++ b/app/[locale]/mcp/McpConnectClient.tsx
@@ -0,0 +1,256 @@
+"use client";
+
+import Link from "next/link";
+import { Check, Copy, ExternalLink } from "lucide-react";
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { getStoredToken } from "@/lib/use-auth";
+import {
+ buildMcpClientSnippets,
+ MCP_CLIENT_REGISTRY,
+ type McpClientId,
+ type McpConnectLocale,
+ type McpConnectMode,
+} from "@/lib/mcp/connect-snippets";
+
+interface McpConnectClientProps {
+ locale: McpConnectLocale;
+}
+
+export function McpConnectClient({ locale }: McpConnectClientProps) {
+ const t = useTranslations("mcpConnect");
+ const [mode, setMode] = useState("publish");
+ const [clientId, setClientId] = useState("claude-code");
+ const [token, setToken] = useState(null);
+ const [copyStatus, setCopyStatus] = useState<{
+ id: string;
+ state: "copied" | "failed";
+ } | null>(null);
+
+ useEffect(() => {
+ const storedToken = getStoredToken();
+ Promise.resolve().then(() => setToken(storedToken));
+ }, []);
+
+ const blocks = buildMcpClientSnippets(clientId, {
+ token,
+ mode,
+ locale,
+ });
+
+ async function copy(value: string, id: string) {
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopyStatus({ id, state: "copied" });
+ } catch {
+ setCopyStatus({ id, state: "failed" });
+ }
+ }
+
+ function copyLabel(id: string) {
+ if (copyStatus?.id !== id) return t("copy.copy");
+ return t(`copy.${copyStatus.state}`);
+ }
+
+ return (
+
+
+
+ {t("eyebrow")}
+
+
+ {t("title")}
+
+
+ {t("intro")}
+
+
+ {t("privacy")}
+
+
+
+
+
+ {t("mode.label")}
+
+
+ {(["search", "publish"] as const).map((candidate) => (
+
+ ))}
+
+
+
+ {token ? (
+
+
+
+ {t("auth.tokenReady")}
+
+
+ ••••••••{token.slice(-6)}
+
+
+
+
+ ) : (
+
+ {t("auth.loginHint")}{" "}
+
+ {t("auth.loginLink")}
+
+
+ )}
+
+
+
+
+ {t("clients.label")}
+
+
+ {MCP_CLIENT_REGISTRY.map((client) => (
+
+ ))}
+
+
+
+
+
+
+ {t(`clients.${clientId}`)}
+
+
+
+
+ {blocks.map((block) => {
+ if (block.kind === "code") {
+ const copyId = `${clientId}-${mode}-${block.id}`;
+ return (
+
+
+
+ {t(`blocks.${block.title}`)}
+
+ {block.detail ? (
+
+ {block.detail}
+
+ ) : null}
+
+
+
+ {block.content}
+
+
+
+
+ );
+ }
+
+ if (block.kind === "link") {
+ return (
+
+
+ {t(`blocks.${block.title}`)}
+
+
+ {t(`messages.${block.messageKey}`)}
+
+
+
+ );
+ }
+
+ return (
+
+ {t(`messages.${block.messageKey}`)}
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/app/[locale]/mcp/page.tsx b/app/[locale]/mcp/page.tsx
new file mode 100644
index 00000000..62ea060a
--- /dev/null
+++ b/app/[locale]/mcp/page.tsx
@@ -0,0 +1,46 @@
+import type { Metadata } from "next";
+import { hasLocale } from "next-intl";
+import { setRequestLocale } from "next-intl/server";
+import { notFound } from "next/navigation";
+import { Header } from "@/app/components/Header";
+import { Footer } from "@/app/components/Footer";
+import { routing } from "@/i18n/routing";
+import { McpConnectClient } from "./McpConnectClient";
+
+export const metadata: Metadata = {
+ title: "MCP 连接 / MCP Connect · Involution Hell",
+ description:
+ "连接 Involution Hell MCP:为 Claude Code、Codex、Cursor、VS Code 等客户端生成可复制的搜索与发布配置。Connect your MCP client with ready-to-copy search and publishing setup.",
+ alternates: { canonical: "/mcp" },
+ openGraph: {
+ title: "MCP Connect · Involution Hell",
+ description:
+ "Ready-to-copy MCP setup for searching and publishing Involution Hell content.",
+ url: "/mcp",
+ type: "website",
+ },
+};
+
+interface Props {
+ params: Promise<{ locale: string }>;
+}
+
+export default async function McpConnectPage({ params }: Props) {
+ const { locale } = await params;
+ if (!hasLocale(routing.locales, locale)) notFound();
+ setRequestLocale(locale);
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
+
+export function generateStaticParams() {
+ return routing.locales.map((locale) => ({ locale }));
+}
diff --git a/app/components/Header.tsx b/app/components/Header.tsx
index acf20afe..0183f3c3 100644
--- a/app/components/Header.tsx
+++ b/app/components/Header.tsx
@@ -42,6 +42,15 @@ export async function Header() {
>
{t("nav.rank")}
+
+ {t("nav.mcp")}
+
) => McpSnippetBlock[];
+}
+
+function json(value: unknown): string {
+ return JSON.stringify(value);
+}
+
+function resolvedToken({ token, locale }: Required): string {
+ if (token) return token;
+ return locale === "zh" ? "<你的satoken>" : "";
+}
+
+function claudeCodeBlocks(
+ options: Required,
+): McpSnippetBlock[] {
+ const token = resolvedToken(options);
+ const publish = options.mode === "publish";
+ const server = {
+ type: "http",
+ url: MCP_URL,
+ ...(publish ? { headers: { Authorization: `Bearer ${token}` } } : {}),
+ };
+
+ return [
+ {
+ id: "cli",
+ kind: "code",
+ title: "cli",
+ content: `claude mcp add --transport http involutionhell ${MCP_URL}${
+ publish ? ` --header "Authorization: Bearer ${token}"` : ""
+ }`,
+ },
+ {
+ id: "config",
+ kind: "code",
+ title: "config",
+ detail: ".mcp.json project root or ~/.claude.json",
+ content: json({ mcpServers: { involutionhell: server } }),
+ },
+ ];
+}
+
+function codexBlocks(options: Required): McpSnippetBlock[] {
+ const publish = options.mode === "publish";
+ const token = resolvedToken(options);
+ const command = `codex mcp add involutionhell --url ${MCP_URL}${
+ publish ? ` --bearer-token-env-var ${CODEX_TOKEN_ENV}` : ""
+ }`;
+ const config = [
+ "[mcp_servers.involutionhell]",
+ `url = "${MCP_URL}"`,
+ ...(publish ? [`bearer_token_env_var = "${CODEX_TOKEN_ENV}"`] : []),
+ ].join("\n");
+
+ return [
+ {
+ id: "cli",
+ kind: "code",
+ title: "cli",
+ content: publish
+ ? `export ${CODEX_TOKEN_ENV}=${token}\n${command}`
+ : command,
+ },
+ {
+ id: "config",
+ kind: "code",
+ title: "config",
+ detail: "~/.codex/config.toml",
+ content: config,
+ },
+ ];
+}
+
+function openCodeBlocks(
+ options: Required,
+): McpSnippetBlock[] {
+ const publish = options.mode === "publish";
+ const token = resolvedToken(options);
+ const server = {
+ type: "remote",
+ url: MCP_URL,
+ enabled: true,
+ ...(publish ? { headers: { Authorization: `Bearer ${token}` } } : {}),
+ };
+
+ return [
+ {
+ id: "config",
+ kind: "code",
+ title: "config",
+ detail: "opencode.json project or ~/.config/opencode/opencode.json",
+ content: json({
+ $schema: "https://opencode.ai/config.json",
+ mcp: { involutionhell: server },
+ }),
+ },
+ ];
+}
+
+function geminiBlocks(options: Required): McpSnippetBlock[] {
+ const publish = options.mode === "publish";
+ const token = resolvedToken(options);
+ const server = {
+ httpUrl: MCP_URL,
+ ...(publish ? { headers: { Authorization: `Bearer ${token}` } } : {}),
+ };
+
+ return [
+ {
+ id: "cli",
+ kind: "code",
+ title: "cli",
+ content: `gemini mcp add --transport http involutionhell ${MCP_URL}${
+ publish ? ` --header "Authorization: Bearer ${token}"` : ""
+ }`,
+ },
+ {
+ id: "config",
+ kind: "code",
+ title: "config",
+ detail: "~/.gemini/settings.json",
+ content: json({ mcpServers: { involutionhell: server } }),
+ },
+ ];
+}
+
+function cursorBlocks(options: Required): McpSnippetBlock[] {
+ const publish = options.mode === "publish";
+ const token = resolvedToken(options);
+ const blocks: McpSnippetBlock[] = [];
+
+ if (publish) {
+ blocks.push({
+ id: "environment",
+ kind: "code",
+ title: "environment",
+ content: `export ${CODEX_TOKEN_ENV}=${token}`,
+ });
+ }
+
+ blocks.push({
+ id: "config",
+ kind: "code",
+ title: "config",
+ detail: "~/.cursor/mcp.json or .cursor/mcp.json",
+ content: json({
+ mcpServers: {
+ involutionhell: {
+ url: MCP_URL,
+ ...(publish
+ ? {
+ headers: {
+ Authorization: `Bearer \${env:${CODEX_TOKEN_ENV}}`,
+ },
+ }
+ : {}),
+ },
+ },
+ }),
+ });
+
+ return blocks;
+}
+
+function vscodeBlocks(options: Required): McpSnippetBlock[] {
+ const publish = options.mode === "publish";
+ const server = {
+ type: "http",
+ url: MCP_URL,
+ ...(publish
+ ? { headers: { Authorization: "Bearer ${input:ih-token}" } }
+ : {}),
+ };
+
+ return [
+ {
+ id: "config",
+ kind: "code",
+ title: "config",
+ detail: ".vscode/mcp.json",
+ content: json({
+ ...(publish
+ ? {
+ inputs: [
+ {
+ type: "promptString",
+ id: "ih-token",
+ description: "InvolutionHell token",
+ password: true,
+ },
+ ],
+ }
+ : {}),
+ servers: { involutionhell: server },
+ }),
+ },
+ ...(publish
+ ? ([
+ {
+ id: "prompt-note",
+ kind: "message",
+ messageKey: "vscodePrompt",
+ },
+ ] satisfies McpSnippetBlock[])
+ : []),
+ ];
+}
+
+function webBlocks(client: "claude-ai" | "chatgpt"): McpSnippetBlock[] {
+ return [
+ {
+ id: "steps",
+ kind: "message",
+ messageKey: client === "claude-ai" ? "claudeAiSteps" : "chatgptSteps",
+ },
+ {
+ id: "search-only",
+ kind: "message",
+ messageKey: "webSearchOnly",
+ tone: "notice",
+ },
+ ];
+}
+
+function piBlocks(options: Required): McpSnippetBlock[] {
+ const token = resolvedToken(options);
+ const command =
+ 'npx --yes github:InvolutionHell/involutionhell-agent-tools search "..."';
+
+ return [
+ {
+ id: "no-mcp",
+ kind: "message",
+ messageKey: "piNoMcp",
+ tone: "notice",
+ },
+ {
+ id: "command",
+ kind: "code",
+ title: "command",
+ content:
+ options.mode === "publish"
+ ? `export ${PI_TOKEN_ENV}=${token}\n${command}`
+ : command,
+ },
+ {
+ id: "skill",
+ kind: "link",
+ title: "skill",
+ messageKey: "piSkill",
+ href: "https://github.com/InvolutionHell/involutionhell-agent-tools",
+ },
+ ];
+}
+
+export const MCP_CLIENT_REGISTRY: readonly McpClientDefinition[] = [
+ { id: "claude-code", build: claudeCodeBlocks },
+ { id: "codex", build: codexBlocks },
+ { id: "opencode", build: openCodeBlocks },
+ { id: "gemini", build: geminiBlocks },
+ { id: "cursor", build: cursorBlocks },
+ { id: "vscode", build: vscodeBlocks },
+ { id: "claude-ai", build: () => webBlocks("claude-ai") },
+ { id: "chatgpt", build: () => webBlocks("chatgpt") },
+ { id: "pi", build: piBlocks },
+];
+
+function normalizeOptions(
+ options: McpSnippetOptions,
+): Required {
+ return { ...options, locale: options.locale ?? "en" };
+}
+
+export function buildMcpClientSnippets(
+ clientId: McpClientId,
+ options: McpSnippetOptions,
+): McpSnippetBlock[] {
+ const client = MCP_CLIENT_REGISTRY.find(({ id }) => id === clientId);
+ if (!client) return [];
+ return client.build(normalizeOptions(options));
+}
+
+export function buildMcpConnectSnippets(
+ options: McpSnippetOptions,
+): McpClientSnippets[] {
+ const normalized = normalizeOptions(options);
+ return MCP_CLIENT_REGISTRY.map(({ id, build }) => ({
+ id,
+ blocks: build(normalized),
+ }));
+}
diff --git a/lib/use-auth.tsx b/lib/use-auth.tsx
index 9738616f..f5f5e9cd 100644
--- a/lib/use-auth.tsx
+++ b/lib/use-auth.tsx
@@ -31,7 +31,7 @@ const AuthContext = createContext({
});
// 从 localStorage 读取 satoken
-function getStoredToken(): string | null {
+export function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("satoken");
}
diff --git a/messages/en.json b/messages/en.json
index afb37cdd..5664bbf1 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3,11 +3,61 @@
"nav": {
"docs": "Docs",
"rank": "Rank",
+ "mcp": "MCP",
"community": "Community",
"feed": "Feed",
"contact": "Contact"
}
},
+ "mcpConnect": {
+ "eyebrow": "Model Context Protocol · Setup",
+ "title": "MCP Connect",
+ "intro": "Connect Involution Hell to your preferred AI client. Search the knowledge base anonymously, or add your signed-in credentials to publish. Pick a client and copy the verified command or config in one click.",
+ "privacy": "Your satoken is read only from this browser's local storage and inserted on this page. It is never sent to analytics or another endpoint.",
+ "mode": {
+ "label": "Connection mode",
+ "search": "Search only (anonymous)",
+ "publish": "Publish enabled (with token)"
+ },
+ "auth": {
+ "tokenReady": "Configs are filled with the satoken for your signed-in account",
+ "copyToken": "Copy my satoken",
+ "loginHint": "No signed-in credential was found, so publish configs use the placeholder shown below. Sign in first, then return to copy a complete config.",
+ "loginLink": "Sign in"
+ },
+ "copy": {
+ "copy": "Copy",
+ "copied": "Copied",
+ "failed": "Copy failed"
+ },
+ "clients": {
+ "label": "Choose a client",
+ "claude-code": "Claude Code",
+ "codex": "Codex CLI",
+ "opencode": "OpenCode",
+ "gemini": "Gemini CLI",
+ "cursor": "Cursor",
+ "vscode": "VS Code (Copilot)",
+ "claude-ai": "claude.ai (Web)",
+ "chatgpt": "ChatGPT (Web, developer mode)",
+ "pi": "Pi"
+ },
+ "blocks": {
+ "cli": "CLI command",
+ "config": "Config file",
+ "environment": "Environment variable",
+ "command": "Companion CLI",
+ "skill": "Companion skill"
+ },
+ "messages": {
+ "claudeAiSteps": "Settings → Connectors → Add custom connector → paste https://involutionhell.com/api/mcp.",
+ "chatgptSteps": "Enable Developer mode → Settings → Connectors → Create → enter https://involutionhell.com/api/mcp.",
+ "webSearchOnly": "The web client currently supports only OAuth or unauthenticated connections, so it can search anonymously but cannot publish until OAuth is available.",
+ "vscodePrompt": "VS Code prompts for the token when the server first starts. Copy “Copy my satoken” above and paste it into that prompt.",
+ "piNoMcp": "Pi does not support MCP by design. Use the companion Involution Hell skill and CLI instead.",
+ "piSkill": "Install the companion skill from InvolutionHell/involutionhell-agent-tools"
+ }
+ },
"hero": {
"mission": "A free, open learning community built by developers, for developers. No gatekeeping, no pointless grind — just real progress and the joy of building. Knowledge is a ladder to freedom, not a cage.",
"cta": {
diff --git a/messages/zh.json b/messages/zh.json
index 8190779a..51d7a0cf 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -3,11 +3,61 @@
"nav": {
"docs": "文档",
"rank": "排行榜",
+ "mcp": "MCP",
"community": "社区",
"feed": "分享墙",
"contact": "联系"
}
},
+ "mcpConnect": {
+ "eyebrow": "Model Context Protocol · Setup",
+ "title": "MCP 连接",
+ "intro": "把 Involution Hell 接入你常用的 AI 客户端:匿名搜索站内知识,或带上登录凭证直接发布内容。选择客户端后即可一键复制经过验证的命令与配置。",
+ "privacy": "satoken 只从当前浏览器的本地存储读取并填入页面,不会被发送到分析服务或其他端点。",
+ "mode": {
+ "label": "连接模式",
+ "search": "仅搜索(匿名)",
+ "publish": "可发布(带 token)"
+ },
+ "auth": {
+ "tokenReady": "已使用当前登录账号的 satoken 填充配置",
+ "copyToken": "复制我的 satoken",
+ "loginHint": "当前未读取到登录凭证,发布配置会使用下方占位符。请先登录,再回来复制完整配置。",
+ "loginLink": "前往登录"
+ },
+ "copy": {
+ "copy": "复制",
+ "copied": "已复制",
+ "failed": "复制失败"
+ },
+ "clients": {
+ "label": "选择客户端",
+ "claude-code": "Claude Code",
+ "codex": "Codex CLI",
+ "opencode": "OpenCode",
+ "gemini": "Gemini CLI",
+ "cursor": "Cursor",
+ "vscode": "VS Code (Copilot)",
+ "claude-ai": "claude.ai(网页)",
+ "chatgpt": "ChatGPT(网页,developer mode)",
+ "pi": "Pi"
+ },
+ "blocks": {
+ "cli": "CLI 命令",
+ "config": "配置文件",
+ "environment": "环境变量",
+ "command": "配套 CLI",
+ "skill": "配套 Skill"
+ },
+ "messages": {
+ "claudeAiSteps": "设置 → Connectors → Add custom connector → 粘贴 https://involutionhell.com/api/mcp。",
+ "chatgptSteps": "开启 Developer mode → Settings → Connectors → Create → 填入 https://involutionhell.com/api/mcp。",
+ "webSearchOnly": "网页端仅支持 OAuth/无鉴权,当前只能匿名搜索;发布功能需等待 OAuth 上线。",
+ "vscodePrompt": "VS Code 首次启动连接时会提示输入 token;请复制上方“复制我的 satoken”一行并粘贴。",
+ "piNoMcp": "Pi 官方不做 MCP(by design)。请改用 Involution Hell 配套的 Skill 与 CLI。",
+ "piSkill": "从 InvolutionHell/involutionhell-agent-tools 安装配套 Skill"
+ }
+ },
"hero": {
"mission": "一个由开发者自发组织、免费开放的学习社区。降低门槛,避免无意义内卷,专注真实进步与乐趣。我们相信知识不应成为枷锁,而应是通往自由的阶梯。",
"cta": {
diff --git a/tests/mcp-connect-snippets.test.ts b/tests/mcp-connect-snippets.test.ts
new file mode 100644
index 00000000..2cc36443
--- /dev/null
+++ b/tests/mcp-connect-snippets.test.ts
@@ -0,0 +1,137 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildMcpClientSnippets,
+ buildMcpConnectSnippets,
+ type McpClientId,
+ type McpSnippetOptions,
+} from "@/lib/mcp/connect-snippets";
+
+function codeBlock(
+ clientId: McpClientId,
+ options: McpSnippetOptions,
+ id: string,
+) {
+ const block = buildMcpClientSnippets(clientId, options).find(
+ (candidate) => candidate.id === id,
+ );
+ if (!block || block.kind !== "code") {
+ throw new Error(`Missing code block ${clientId}/${id}`);
+ }
+ return block;
+}
+
+describe("MCP connect snippet builders", () => {
+ it("injects a logged-in token into Claude Code publish snippets", () => {
+ const options = { token: "token-abc-123", mode: "publish" } as const;
+ const cli = codeBlock("claude-code", options, "cli").content;
+ const config = codeBlock("claude-code", options, "config").content;
+
+ expect(cli).toBe(
+ 'claude mcp add --transport http involutionhell https://involutionhell.com/api/mcp --header "Authorization: Bearer token-abc-123"',
+ );
+ expect(config).toContain('"Authorization":"Bearer token-abc-123"');
+ });
+
+ it("uses the locale-specific placeholder when no token is available", () => {
+ const zh = codeBlock(
+ "claude-code",
+ { token: null, mode: "publish", locale: "zh" },
+ "cli",
+ ).content;
+ const en = codeBlock(
+ "claude-code",
+ { token: null, mode: "publish", locale: "en" },
+ "cli",
+ ).content;
+
+ expect(zh).toContain("<你的satoken>");
+ expect(en).toContain("");
+ });
+
+ it("removes Authorization and token setup from every anonymous client", () => {
+ const rendered = JSON.stringify(
+ buildMcpConnectSnippets({
+ token: "must-not-appear",
+ mode: "search",
+ locale: "en",
+ }),
+ );
+
+ expect(rendered).not.toContain("Authorization");
+ expect(rendered).not.toContain("must-not-appear");
+ expect(rendered).not.toContain("INVOLUTIONHELL_TOKEN");
+ expect(rendered).not.toContain("INVOLUTIONHELL_SATOKEN");
+ });
+
+ it("uses httpUrl rather than url in Gemini settings", () => {
+ const block = codeBlock(
+ "gemini",
+ { token: "gemini-token", mode: "publish" },
+ "config",
+ );
+ const config = JSON.parse(block.content);
+ const server = config.mcpServers.involutionhell;
+
+ expect(server.httpUrl).toBe("https://involutionhell.com/api/mcp");
+ expect(server).not.toHaveProperty("url");
+ expect(server.headers.Authorization).toBe("Bearer gemini-token");
+ });
+
+ it("uses VS Code inputs and a top-level servers key", () => {
+ const block = codeBlock(
+ "vscode",
+ { token: "not-embedded", mode: "publish" },
+ "config",
+ );
+ const config = JSON.parse(block.content);
+
+ expect(block.detail).toBe(".vscode/mcp.json");
+ expect(config).toHaveProperty("servers.involutionhell");
+ expect(config).not.toHaveProperty("mcpServers");
+ expect(config.inputs[0]).toMatchObject({
+ type: "promptString",
+ id: "ih-token",
+ password: true,
+ });
+ expect(config.servers.involutionhell.headers.Authorization).toBe(
+ "Bearer ${input:ih-token}",
+ );
+ expect(block.content).not.toContain("not-embedded");
+ });
+
+ it("stores the Codex bearer env-var name instead of its value", () => {
+ const options = { token: "codex-secret", mode: "publish" } as const;
+ const cli = codeBlock("codex", options, "cli").content;
+ const config = codeBlock("codex", options, "config").content;
+
+ expect(cli).toContain("export INVOLUTIONHELL_TOKEN=codex-secret");
+ expect(cli).toContain("--bearer-token-env-var INVOLUTIONHELL_TOKEN");
+ expect(config).toContain('bearer_token_env_var = "INVOLUTIONHELL_TOKEN"');
+ expect(config).not.toContain("codex-secret");
+ });
+
+ it("uses an env reference in Cursor config and keeps the value in a companion line", () => {
+ const options = { token: "cursor-secret", mode: "publish" } as const;
+ const environment = codeBlock("cursor", options, "environment").content;
+ const config = codeBlock("cursor", options, "config").content;
+
+ expect(environment).toBe("export INVOLUTIONHELL_TOKEN=cursor-secret");
+ expect(config).toContain("Bearer ${env:INVOLUTIONHELL_TOKEN}");
+ expect(config).not.toContain("cursor-secret");
+ });
+
+ it("keeps web clients search-only and gives Pi its companion CLI token env", () => {
+ const options = { token: "web-secret", mode: "publish" } as const;
+ const claudeAi = buildMcpClientSnippets("claude-ai", options);
+ const chatgpt = buildMcpClientSnippets("chatgpt", options);
+ const pi = codeBlock("pi", options, "command").content;
+
+ expect(claudeAi.every((block) => block.kind !== "code")).toBe(true);
+ expect(chatgpt.every((block) => block.kind !== "code")).toBe(true);
+ expect(JSON.stringify([claudeAi, chatgpt])).not.toContain("web-secret");
+ expect(pi).toContain("export INVOLUTIONHELL_SATOKEN=web-secret");
+ expect(pi).toContain(
+ 'npx --yes github:InvolutionHell/involutionhell-agent-tools search "..."',
+ );
+ });
+});
From 9e21b93d617afddadc0e10f4c11042437c9c7edb Mon Sep 17 00:00:00 2001
From: Crokily
Date: Sun, 19 Jul 2026 10:40:36 +1000
Subject: [PATCH 4/6] feat(mcp): add CORS support to the MCP endpoint
Browser-hosted MCP clients preflight OPTIONS and need
Access-Control-Allow-Origin on responses. Add an OPTIONS handler and a
centralized CORS wrapper over GET/POST (streaming-safe), exposing
WWW-Authenticate so browser clients can read the auth challenge.
Wildcard origin is safe here: auth is bearer-header based, not cookies.
Co-authored-by: chatgpt-codex-connector[bot]
---
app/api/mcp/route.ts | 23 +++++++++++++++++++---
lib/mcp/request.ts | 24 +++++++++++++++++++++++
tests/mcp-request.test.ts | 41 +++++++++++++++++++++++++++++++++++++++
3 files changed, 85 insertions(+), 3 deletions(-)
diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts
index 468e5b65..cf92b5c1 100644
--- a/app/api/mcp/route.ts
+++ b/app/api/mcp/route.ts
@@ -1,6 +1,10 @@
import { createMcpHandler } from "mcp-handler";
import { protectMcpHandler } from "@/lib/mcp/auth";
-import { createMcpPostHandler } from "@/lib/mcp/request";
+import {
+ createMcpPostHandler,
+ MCP_CORS_HEADERS,
+ withMcpCors,
+} from "@/lib/mcp/request";
import { registerMcpTools } from "@/lib/mcp/tools";
export const runtime = "nodejs";
@@ -23,5 +27,18 @@ const mcpHandler = protectMcpHandler(
),
);
-export const GET = (request: Request): Promise => mcpHandler(request);
-export const POST = createMcpPostHandler(mcpHandler);
+export const GET = withMcpCors(mcpHandler);
+export const POST = withMcpCors(createMcpPostHandler(mcpHandler));
+
+export function OPTIONS(): Response {
+ return new Response(null, {
+ status: 204,
+ headers: {
+ ...MCP_CORS_HEADERS,
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
+ "Access-Control-Allow-Headers":
+ "authorization, content-type, accept, mcp-protocol-version",
+ "Access-Control-Max-Age": "86400",
+ },
+ });
+}
diff --git a/lib/mcp/request.ts b/lib/mcp/request.ts
index 7ba9153d..919fcdb1 100644
--- a/lib/mcp/request.ts
+++ b/lib/mcp/request.ts
@@ -1,5 +1,10 @@
const MAX_BODY_BYTES = 1024 * 1024;
+export const MCP_CORS_HEADERS = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Expose-Headers": "WWW-Authenticate",
+};
+
type ParsedMcpHandler = (
request: Request,
body: unknown,
@@ -7,6 +12,25 @@ type ParsedMcpHandler = (
class BodyTooLargeError extends Error {}
+type McpRouteHandler = (request: Request) => Response | Promise;
+
+export function withMcpCors(
+ handler: McpRouteHandler,
+): (request: Request) => Promise {
+ return async (request) => {
+ const response = await handler(request);
+ const headers = new Headers(response.headers);
+ for (const [name, value] of Object.entries(MCP_CORS_HEADERS)) {
+ headers.set(name, value);
+ }
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers,
+ });
+ };
+}
+
function jsonRpcError(status: number, code: number, message: string): Response {
return Response.json(
{
diff --git a/tests/mcp-request.test.ts b/tests/mcp-request.test.ts
index c381d4d3..39ec18e8 100644
--- a/tests/mcp-request.test.ts
+++ b/tests/mcp-request.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
+import { OPTIONS, POST } from "@/app/api/mcp/route";
import { createMcpPostHandler } from "@/lib/mcp/request";
function post(body: string, headers?: HeadersInit): Request {
@@ -10,6 +11,20 @@ function post(body: string, headers?: HeadersInit): Request {
}
describe("MCP request boundary", () => {
+ it("answers CORS preflight requests", () => {
+ const response = OPTIONS();
+
+ expect(response.status).toBe(204);
+ expect(response.headers.get("access-control-allow-origin")).toBe("*");
+ expect(response.headers.get("access-control-allow-methods")).toBe(
+ "GET, POST, OPTIONS",
+ );
+ expect(response.headers.get("access-control-allow-headers")).toBe(
+ "authorization, content-type, accept, mcp-protocol-version",
+ );
+ expect(response.headers.get("access-control-max-age")).toBe("86400");
+ });
+
it("returns a JSON-RPC parse error without invoking the handler", async () => {
const handler = vi.fn(() => Response.json({ ok: true }));
const response = await createMcpPostHandler(handler)(post("{oops"));
@@ -23,6 +38,32 @@ describe("MCP request boundary", () => {
expect(handler).not.toHaveBeenCalled();
});
+ it("adds CORS headers to parse errors", async () => {
+ const response = await POST(post("{oops"));
+
+ expect(response.status).toBe(400);
+ expect(response.headers.get("access-control-allow-origin")).toBe("*");
+ });
+
+ it("adds CORS headers to missing-token challenges", async () => {
+ const response = await POST(
+ post(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ id: 1,
+ method: "tools/call",
+ params: { name: "publish", arguments: {} },
+ }),
+ ),
+ );
+
+ expect(response.status).toBe(401);
+ expect(response.headers.get("access-control-allow-origin")).toBe("*");
+ expect(response.headers.get("access-control-expose-headers")).toBe(
+ "WWW-Authenticate",
+ );
+ });
+
it("rejects JSON-RPC batches", async () => {
const handler = vi.fn(() => Response.json({ ok: true }));
const response = await createMcpPostHandler(handler)(post("[]"));
From 37cd72a77c4bc96e3507c2dee2579c2196cbc460 Mon Sep 17 00:00:00 2001
From: Crokily
Date: Sun, 19 Jul 2026 11:16:00 +1000
Subject: [PATCH 5/6] feat(mcp): note that CLI and config-file install methods
are both provided
---
messages/en.json | 2 +-
messages/zh.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/messages/en.json b/messages/en.json
index 5664bbf1..4bd50466 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -12,7 +12,7 @@
"mcpConnect": {
"eyebrow": "Model Context Protocol · Setup",
"title": "MCP Connect",
- "intro": "Connect Involution Hell to your preferred AI client. Search the knowledge base anonymously, or add your signed-in credentials to publish. Pick a client and copy the verified command or config in one click.",
+ "intro": "Connect Involution Hell to your preferred AI client. Search the knowledge base anonymously, or add your signed-in credentials to publish. Pick a client and copy the verified command or config in one click. Both CLI-command and config-file installation methods are provided — either one works.",
"privacy": "Your satoken is read only from this browser's local storage and inserted on this page. It is never sent to analytics or another endpoint.",
"mode": {
"label": "Connection mode",
diff --git a/messages/zh.json b/messages/zh.json
index 51d7a0cf..49b4c175 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -12,7 +12,7 @@
"mcpConnect": {
"eyebrow": "Model Context Protocol · Setup",
"title": "MCP 连接",
- "intro": "把 Involution Hell 接入你常用的 AI 客户端:匿名搜索站内知识,或带上登录凭证直接发布内容。选择客户端后即可一键复制经过验证的命令与配置。",
+ "intro": "把 Involution Hell 接入你常用的 AI 客户端:匿名搜索站内知识,或带上登录凭证直接发布内容。选择客户端后即可一键复制经过验证的命令与配置。CLI 命令和配置文件的安装方式均有提供,任选其一即可。",
"privacy": "satoken 只从当前浏览器的本地存储读取并填入页面,不会被发送到分析服务或其他端点。",
"mode": {
"label": "连接模式",
From 9f6aaac8b7587946d3b2599b6efbc1f57865b052 Mon Sep 17 00:00:00 2001
From: Crokily
Date: Sun, 19 Jul 2026 11:26:44 +1000
Subject: [PATCH 6/6] feat(mcp): derive connect-page MCP endpoint from site
origin
The /mcp page now builds install commands against the current origin
after mount, so local dev and Vercel previews produce directly usable
/api/mcp URLs while prerendered HTML keeps the production default.
---
app/[locale]/mcp/McpConnectClient.tsx | 10 +++++--
dev_docs/mcp_server.md | 4 +--
lib/mcp/connect-snippets.ts | 38 +++++++++++++++++----------
messages/en.json | 4 +--
messages/zh.json | 4 +--
tests/mcp-connect-snippets.test.ts | 32 ++++++++++++++++++++++
6 files changed, 70 insertions(+), 22 deletions(-)
diff --git a/app/[locale]/mcp/McpConnectClient.tsx b/app/[locale]/mcp/McpConnectClient.tsx
index 7923a0e5..326207ae 100644
--- a/app/[locale]/mcp/McpConnectClient.tsx
+++ b/app/[locale]/mcp/McpConnectClient.tsx
@@ -22,6 +22,7 @@ export function McpConnectClient({ locale }: McpConnectClientProps) {
const [mode, setMode] = useState("publish");
const [clientId, setClientId] = useState("claude-code");
const [token, setToken] = useState(null);
+ const [serverUrl, setServerUrl] = useState();
const [copyStatus, setCopyStatus] = useState<{
id: string;
state: "copied" | "failed";
@@ -29,13 +30,18 @@ export function McpConnectClient({ locale }: McpConnectClientProps) {
useEffect(() => {
const storedToken = getStoredToken();
- Promise.resolve().then(() => setToken(storedToken));
+ const currentServerUrl = new URL("/api/mcp", window.location.origin).href;
+ Promise.resolve().then(() => {
+ setToken(storedToken);
+ setServerUrl(currentServerUrl);
+ });
}, []);
const blocks = buildMcpClientSnippets(clientId, {
token,
mode,
locale,
+ serverUrl,
});
async function copy(value: string, id: string) {
@@ -244,7 +250,7 @@ export function McpConnectClient({ locale }: McpConnectClientProps) {
: "border-[var(--foreground)]"
}`}
>
- {t(`messages.${block.messageKey}`)}
+ {t(`messages.${block.messageKey}`, block.values)}
);
})}
diff --git a/dev_docs/mcp_server.md b/dev_docs/mcp_server.md
index e4e64d3a..b7a52b10 100644
--- a/dev_docs/mcp_server.md
+++ b/dev_docs/mcp_server.md
@@ -120,7 +120,7 @@ sa-token 当前有效期是 30 天。网页端暂时没有安全的 OAuth 授权
## 本地开发
-面向用户时,推荐直接访问站内 `/mcp` 页面获取各客户端的安装命令与配置。页面会在浏览器挂载后读取当前登录用户的 satoken,并自动填入可发布版本;未登录时只显示占位符和登录提示。
+面向用户时,推荐直接访问站内 `/mcp` 页面获取各客户端的安装命令与配置。页面会在浏览器挂载后读取当前站点的 origin 作为 MCP endpoint,因此本地开发和 Vercel Preview 会分别生成各自可用的 `/api/mcp` URL,生产站仍生成 `https://involutionhell.com/api/mcp`。页面同时读取当前登录用户的 satoken,并自动填入可发布版本;未登录时只显示占位符和登录提示。
开发者仍可用下面的命令手动连接:
@@ -145,7 +145,7 @@ claude mcp add --transport http involutionhell https://involutionhell.com/api/mc
--header "Authorization: Bearer ${INVOLUTIONHELL_SATOKEN}"
```
-本地 endpoint 可替换为 `http://localhost:3000/api/mcp`。
+手写命令时,本地 endpoint 可替换为 `http://localhost:3000/api/mcp`;从本地 `/mcp` 页面复制时会自动使用该地址。
## 已知限制
diff --git a/lib/mcp/connect-snippets.ts b/lib/mcp/connect-snippets.ts
index 2830780a..1bd85d70 100644
--- a/lib/mcp/connect-snippets.ts
+++ b/lib/mcp/connect-snippets.ts
@@ -1,4 +1,4 @@
-const MCP_URL = "https://involutionhell.com/api/mcp";
+const DEFAULT_MCP_URL = "https://involutionhell.com/api/mcp";
const CODEX_TOKEN_ENV = "INVOLUTIONHELL_TOKEN";
const PI_TOKEN_ENV = "INVOLUTIONHELL_SATOKEN";
@@ -37,6 +37,7 @@ export type McpSnippetBlock =
kind: "message";
messageKey: McpSnippetMessageKey;
tone?: "notice";
+ values?: Record;
}
| {
id: string;
@@ -50,6 +51,7 @@ export interface McpSnippetOptions {
token: string | null;
mode: McpConnectMode;
locale?: McpConnectLocale;
+ serverUrl?: string;
}
export interface McpClientSnippets {
@@ -78,7 +80,7 @@ function claudeCodeBlocks(
const publish = options.mode === "publish";
const server = {
type: "http",
- url: MCP_URL,
+ url: options.serverUrl,
...(publish ? { headers: { Authorization: `Bearer ${token}` } } : {}),
};
@@ -87,7 +89,7 @@ function claudeCodeBlocks(
id: "cli",
kind: "code",
title: "cli",
- content: `claude mcp add --transport http involutionhell ${MCP_URL}${
+ content: `claude mcp add --transport http involutionhell ${options.serverUrl}${
publish ? ` --header "Authorization: Bearer ${token}"` : ""
}`,
},
@@ -104,12 +106,12 @@ function claudeCodeBlocks(
function codexBlocks(options: Required): McpSnippetBlock[] {
const publish = options.mode === "publish";
const token = resolvedToken(options);
- const command = `codex mcp add involutionhell --url ${MCP_URL}${
+ const command = `codex mcp add involutionhell --url ${options.serverUrl}${
publish ? ` --bearer-token-env-var ${CODEX_TOKEN_ENV}` : ""
}`;
const config = [
"[mcp_servers.involutionhell]",
- `url = "${MCP_URL}"`,
+ `url = "${options.serverUrl}"`,
...(publish ? [`bearer_token_env_var = "${CODEX_TOKEN_ENV}"`] : []),
].join("\n");
@@ -139,7 +141,7 @@ function openCodeBlocks(
const token = resolvedToken(options);
const server = {
type: "remote",
- url: MCP_URL,
+ url: options.serverUrl,
enabled: true,
...(publish ? { headers: { Authorization: `Bearer ${token}` } } : {}),
};
@@ -162,7 +164,7 @@ function geminiBlocks(options: Required): McpSnippetBlock[] {
const publish = options.mode === "publish";
const token = resolvedToken(options);
const server = {
- httpUrl: MCP_URL,
+ httpUrl: options.serverUrl,
...(publish ? { headers: { Authorization: `Bearer ${token}` } } : {}),
};
@@ -171,7 +173,7 @@ function geminiBlocks(options: Required): McpSnippetBlock[] {
id: "cli",
kind: "code",
title: "cli",
- content: `gemini mcp add --transport http involutionhell ${MCP_URL}${
+ content: `gemini mcp add --transport http involutionhell ${options.serverUrl}${
publish ? ` --header "Authorization: Bearer ${token}"` : ""
}`,
},
@@ -207,7 +209,7 @@ function cursorBlocks(options: Required): McpSnippetBlock[] {
content: json({
mcpServers: {
involutionhell: {
- url: MCP_URL,
+ url: options.serverUrl,
...(publish
? {
headers: {
@@ -227,7 +229,7 @@ function vscodeBlocks(options: Required): McpSnippetBlock[] {
const publish = options.mode === "publish";
const server = {
type: "http",
- url: MCP_URL,
+ url: options.serverUrl,
...(publish
? { headers: { Authorization: "Bearer ${input:ih-token}" } }
: {}),
@@ -267,12 +269,16 @@ function vscodeBlocks(options: Required): McpSnippetBlock[] {
];
}
-function webBlocks(client: "claude-ai" | "chatgpt"): McpSnippetBlock[] {
+function webBlocks(
+ client: "claude-ai" | "chatgpt",
+ options: Required,
+): McpSnippetBlock[] {
return [
{
id: "steps",
kind: "message",
messageKey: client === "claude-ai" ? "claudeAiSteps" : "chatgptSteps",
+ values: { url: options.serverUrl },
},
{
id: "search-only",
@@ -321,15 +327,19 @@ export const MCP_CLIENT_REGISTRY: readonly McpClientDefinition[] = [
{ id: "gemini", build: geminiBlocks },
{ id: "cursor", build: cursorBlocks },
{ id: "vscode", build: vscodeBlocks },
- { id: "claude-ai", build: () => webBlocks("claude-ai") },
- { id: "chatgpt", build: () => webBlocks("chatgpt") },
+ { id: "claude-ai", build: (options) => webBlocks("claude-ai", options) },
+ { id: "chatgpt", build: (options) => webBlocks("chatgpt", options) },
{ id: "pi", build: piBlocks },
];
function normalizeOptions(
options: McpSnippetOptions,
): Required {
- return { ...options, locale: options.locale ?? "en" };
+ return {
+ ...options,
+ locale: options.locale ?? "en",
+ serverUrl: options.serverUrl ?? DEFAULT_MCP_URL,
+ };
}
export function buildMcpClientSnippets(
diff --git a/messages/en.json b/messages/en.json
index 4bd50466..89e1f8cb 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -50,8 +50,8 @@
"skill": "Companion skill"
},
"messages": {
- "claudeAiSteps": "Settings → Connectors → Add custom connector → paste https://involutionhell.com/api/mcp.",
- "chatgptSteps": "Enable Developer mode → Settings → Connectors → Create → enter https://involutionhell.com/api/mcp.",
+ "claudeAiSteps": "Settings → Connectors → Add custom connector → paste {url}.",
+ "chatgptSteps": "Enable Developer mode → Settings → Connectors → Create → enter {url}.",
"webSearchOnly": "The web client currently supports only OAuth or unauthenticated connections, so it can search anonymously but cannot publish until OAuth is available.",
"vscodePrompt": "VS Code prompts for the token when the server first starts. Copy “Copy my satoken” above and paste it into that prompt.",
"piNoMcp": "Pi does not support MCP by design. Use the companion Involution Hell skill and CLI instead.",
diff --git a/messages/zh.json b/messages/zh.json
index 49b4c175..b72329df 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -50,8 +50,8 @@
"skill": "配套 Skill"
},
"messages": {
- "claudeAiSteps": "设置 → Connectors → Add custom connector → 粘贴 https://involutionhell.com/api/mcp。",
- "chatgptSteps": "开启 Developer mode → Settings → Connectors → Create → 填入 https://involutionhell.com/api/mcp。",
+ "claudeAiSteps": "设置 → Connectors → Add custom connector → 粘贴 {url}。",
+ "chatgptSteps": "开启 Developer mode → Settings → Connectors → Create → 填入 {url}。",
"webSearchOnly": "网页端仅支持 OAuth/无鉴权,当前只能匿名搜索;发布功能需等待 OAuth 上线。",
"vscodePrompt": "VS Code 首次启动连接时会提示输入 token;请复制上方“复制我的 satoken”一行并粘贴。",
"piNoMcp": "Pi 官方不做 MCP(by design)。请改用 Involution Hell 配套的 Skill 与 CLI。",
diff --git a/tests/mcp-connect-snippets.test.ts b/tests/mcp-connect-snippets.test.ts
index 2cc36443..5c99b3c1 100644
--- a/tests/mcp-connect-snippets.test.ts
+++ b/tests/mcp-connect-snippets.test.ts
@@ -77,6 +77,38 @@ describe("MCP connect snippet builders", () => {
expect(server.headers.Authorization).toBe("Bearer gemini-token");
});
+ it("uses the current deployment endpoint when one is provided", () => {
+ const serverUrl = "http://localhost:3000/api/mcp";
+ const endpointClients: McpClientId[] = [
+ "claude-code",
+ "codex",
+ "opencode",
+ "gemini",
+ "cursor",
+ "vscode",
+ "claude-ai",
+ "chatgpt",
+ ];
+
+ for (const clientId of endpointClients) {
+ const rendered = JSON.stringify(
+ buildMcpClientSnippets(clientId, {
+ token: null,
+ mode: "search",
+ locale: "en",
+ serverUrl,
+ }),
+ );
+ expect(rendered).toContain(serverUrl);
+ expect(rendered).not.toContain("https://involutionhell.com/api/mcp");
+ }
+
+ expect(
+ codeBlock("codex", { token: null, mode: "search", serverUrl }, "cli")
+ .content,
+ ).toBe(`codex mcp add involutionhell --url ${serverUrl}`);
+ });
+
it("uses VS Code inputs and a top-level servers key", () => {
const block = codeBlock(
"vscode",