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/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/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/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/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, + }); + }); +}); 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); + }); +});