Skip to content

Commit 8409647

Browse files
committed
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.
1 parent 7812941 commit 8409647

10 files changed

Lines changed: 911 additions & 1 deletion

File tree

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { Check, Copy, ExternalLink } from "lucide-react";
5+
import { useEffect, useState } from "react";
6+
import { useTranslations } from "next-intl";
7+
import { getStoredToken } from "@/lib/use-auth";
8+
import {
9+
buildMcpClientSnippets,
10+
MCP_CLIENT_REGISTRY,
11+
type McpClientId,
12+
type McpConnectLocale,
13+
type McpConnectMode,
14+
} from "@/lib/mcp/connect-snippets";
15+
16+
interface McpConnectClientProps {
17+
locale: McpConnectLocale;
18+
}
19+
20+
export function McpConnectClient({ locale }: McpConnectClientProps) {
21+
const t = useTranslations("mcpConnect");
22+
const [mode, setMode] = useState<McpConnectMode>("publish");
23+
const [clientId, setClientId] = useState<McpClientId>("claude-code");
24+
const [token, setToken] = useState<string | null>(null);
25+
const [copyStatus, setCopyStatus] = useState<{
26+
id: string;
27+
state: "copied" | "failed";
28+
} | null>(null);
29+
30+
useEffect(() => {
31+
const storedToken = getStoredToken();
32+
Promise.resolve().then(() => setToken(storedToken));
33+
}, []);
34+
35+
const blocks = buildMcpClientSnippets(clientId, {
36+
token,
37+
mode,
38+
locale,
39+
});
40+
41+
async function copy(value: string, id: string) {
42+
try {
43+
await navigator.clipboard.writeText(value);
44+
setCopyStatus({ id, state: "copied" });
45+
} catch {
46+
setCopyStatus({ id, state: "failed" });
47+
}
48+
}
49+
50+
function copyLabel(id: string) {
51+
if (copyStatus?.id !== id) return t("copy.copy");
52+
return t(`copy.${copyStatus.state}`);
53+
}
54+
55+
return (
56+
<div className="mx-auto max-w-6xl px-6 lg:px-8">
57+
<header className="mb-10 border-t-4 border-[var(--foreground)] pt-6">
58+
<p className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500">
59+
{t("eyebrow")}
60+
</p>
61+
<h1 className="mt-2 font-serif text-4xl font-black uppercase tracking-tight text-[var(--foreground)] md:text-6xl">
62+
{t("title")}
63+
</h1>
64+
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-neutral-600 dark:text-neutral-400 md:text-base">
65+
{t("intro")}
66+
</p>
67+
<p className="mt-3 max-w-3xl font-mono text-xs leading-relaxed text-neutral-500">
68+
{t("privacy")}
69+
</p>
70+
</header>
71+
72+
<section className="mb-8 border border-[var(--foreground)] p-4 md:p-6">
73+
<h2 className="mb-3 font-mono text-xs font-bold uppercase tracking-widest">
74+
{t("mode.label")}
75+
</h2>
76+
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
77+
{(["search", "publish"] as const).map((candidate) => (
78+
<button
79+
key={candidate}
80+
type="button"
81+
aria-pressed={mode === candidate}
82+
onClick={() => setMode(candidate)}
83+
className={`border px-4 py-3 text-left font-sans text-sm font-bold transition-colors ${
84+
mode === candidate
85+
? "border-[#CC0000] bg-[#CC0000] text-white"
86+
: "border-[var(--foreground)] hover:text-[#CC0000]"
87+
}`}
88+
>
89+
{t(`mode.${candidate}`)}
90+
</button>
91+
))}
92+
</div>
93+
</section>
94+
95+
{token ? (
96+
<section className="mb-8 flex flex-col gap-3 border border-emerald-700 bg-emerald-50 p-4 text-emerald-950 dark:bg-emerald-950/30 dark:text-emerald-100 sm:flex-row sm:items-center sm:justify-between">
97+
<div>
98+
<div className="font-sans text-sm font-bold">
99+
{t("auth.tokenReady")}
100+
</div>
101+
<div className="mt-1 font-mono text-xs opacity-70">
102+
••••••••{token.slice(-6)}
103+
</div>
104+
</div>
105+
<button
106+
type="button"
107+
onClick={() => copy(token, "satoken")}
108+
className="inline-flex items-center justify-center gap-2 border border-current px-4 py-2 font-mono text-xs font-bold uppercase tracking-wider hover:bg-emerald-900 hover:text-white"
109+
>
110+
{copyStatus?.id === "satoken" && copyStatus.state === "copied" ? (
111+
<Check className="size-4" aria-hidden="true" />
112+
) : (
113+
<Copy className="size-4" aria-hidden="true" />
114+
)}
115+
{copyStatus?.id === "satoken"
116+
? t(`copy.${copyStatus.state}`)
117+
: t("auth.copyToken")}
118+
</button>
119+
</section>
120+
) : (
121+
<section className="mb-8 border border-amber-700 bg-amber-50 p-4 text-sm leading-relaxed text-amber-950 dark:bg-amber-950/30 dark:text-amber-100">
122+
{t("auth.loginHint")}{" "}
123+
<Link
124+
href="/login"
125+
className="font-bold underline underline-offset-4"
126+
>
127+
{t("auth.loginLink")}
128+
</Link>
129+
</section>
130+
)}
131+
132+
<div className="grid gap-8 lg:grid-cols-[15rem_minmax(0,1fr)]">
133+
<section aria-label={t("clients.label")}>
134+
<h2 className="mb-3 font-mono text-xs font-bold uppercase tracking-widest">
135+
{t("clients.label")}
136+
</h2>
137+
<div
138+
role="tablist"
139+
aria-orientation="vertical"
140+
className="grid grid-cols-2 border-l border-t border-[var(--foreground)] sm:grid-cols-3 lg:grid-cols-1"
141+
>
142+
{MCP_CLIENT_REGISTRY.map((client) => (
143+
<button
144+
key={client.id}
145+
id={`mcp-client-${client.id}`}
146+
type="button"
147+
role="tab"
148+
aria-selected={clientId === client.id}
149+
aria-controls="mcp-client-panel"
150+
onClick={() => setClientId(client.id)}
151+
className={`border-b border-r border-[var(--foreground)] px-3 py-3 text-left font-sans text-xs font-bold transition-colors ${
152+
clientId === client.id
153+
? "bg-[var(--foreground)] text-[var(--background)]"
154+
: "hover:text-[#CC0000]"
155+
}`}
156+
>
157+
{t(`clients.${client.id}`)}
158+
</button>
159+
))}
160+
</div>
161+
</section>
162+
163+
<section
164+
id="mcp-client-panel"
165+
role="tabpanel"
166+
aria-labelledby={`mcp-client-${clientId}`}
167+
className="min-w-0"
168+
>
169+
<div className="mb-4 border-b-2 border-[var(--foreground)] pb-3">
170+
<h2 className="font-serif text-2xl font-black">
171+
{t(`clients.${clientId}`)}
172+
</h2>
173+
</div>
174+
175+
<div className="space-y-5">
176+
{blocks.map((block) => {
177+
if (block.kind === "code") {
178+
const copyId = `${clientId}-${mode}-${block.id}`;
179+
return (
180+
<article key={block.id}>
181+
<div className="mb-2 flex flex-wrap items-baseline justify-between gap-2">
182+
<h3 className="font-mono text-xs font-bold uppercase tracking-widest">
183+
{t(`blocks.${block.title}`)}
184+
</h3>
185+
{block.detail ? (
186+
<span className="break-all font-mono text-[11px] text-neutral-500">
187+
{block.detail}
188+
</span>
189+
) : null}
190+
</div>
191+
<div className="relative border border-[var(--foreground)] bg-neutral-950 text-neutral-100">
192+
<pre className="overflow-x-auto whitespace-pre-wrap break-words p-4 pr-28 font-mono text-xs leading-6">
193+
<code>{block.content}</code>
194+
</pre>
195+
<button
196+
type="button"
197+
onClick={() => copy(block.content, copyId)}
198+
className="absolute right-2 top-2 inline-flex items-center gap-1.5 border border-neutral-600 bg-neutral-950 px-2.5 py-1.5 font-mono text-[10px] uppercase tracking-wider hover:border-white"
199+
aria-label={`${copyLabel(copyId)}: ${t(
200+
`blocks.${block.title}`,
201+
)}`}
202+
>
203+
{copyStatus?.id === copyId &&
204+
copyStatus.state === "copied" ? (
205+
<Check className="size-3.5" aria-hidden="true" />
206+
) : (
207+
<Copy className="size-3.5" aria-hidden="true" />
208+
)}
209+
{copyLabel(copyId)}
210+
</button>
211+
</div>
212+
</article>
213+
);
214+
}
215+
216+
if (block.kind === "link") {
217+
return (
218+
<article key={block.id}>
219+
<h3 className="mb-2 font-mono text-xs font-bold uppercase tracking-widest">
220+
{t(`blocks.${block.title}`)}
221+
</h3>
222+
<a
223+
href={block.href}
224+
target="_blank"
225+
rel="noopener noreferrer"
226+
className="flex items-center justify-between gap-3 border border-[var(--foreground)] p-4 text-sm font-bold hover:text-[#CC0000]"
227+
>
228+
<span>{t(`messages.${block.messageKey}`)}</span>
229+
<ExternalLink
230+
className="size-4 shrink-0"
231+
aria-hidden="true"
232+
/>
233+
</a>
234+
</article>
235+
);
236+
}
237+
238+
return (
239+
<p
240+
key={block.id}
241+
className={`border p-4 text-sm leading-relaxed ${
242+
block.tone === "notice"
243+
? "border-amber-700 bg-amber-50 text-amber-950 dark:bg-amber-950/30 dark:text-amber-100"
244+
: "border-[var(--foreground)]"
245+
}`}
246+
>
247+
{t(`messages.${block.messageKey}`)}
248+
</p>
249+
);
250+
})}
251+
</div>
252+
</section>
253+
</div>
254+
</div>
255+
);
256+
}

app/[locale]/mcp/page.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { Metadata } from "next";
2+
import { hasLocale } from "next-intl";
3+
import { setRequestLocale } from "next-intl/server";
4+
import { notFound } from "next/navigation";
5+
import { Header } from "@/app/components/Header";
6+
import { Footer } from "@/app/components/Footer";
7+
import { routing } from "@/i18n/routing";
8+
import { McpConnectClient } from "./McpConnectClient";
9+
10+
export const metadata: Metadata = {
11+
title: "MCP 连接 / MCP Connect · Involution Hell",
12+
description:
13+
"连接 Involution Hell MCP:为 Claude Code、Codex、Cursor、VS Code 等客户端生成可复制的搜索与发布配置。Connect your MCP client with ready-to-copy search and publishing setup.",
14+
alternates: { canonical: "/mcp" },
15+
openGraph: {
16+
title: "MCP Connect · Involution Hell",
17+
description:
18+
"Ready-to-copy MCP setup for searching and publishing Involution Hell content.",
19+
url: "/mcp",
20+
type: "website",
21+
},
22+
};
23+
24+
interface Props {
25+
params: Promise<{ locale: string }>;
26+
}
27+
28+
export default async function McpConnectPage({ params }: Props) {
29+
const { locale } = await params;
30+
if (!hasLocale(routing.locales, locale)) notFound();
31+
setRequestLocale(locale);
32+
33+
return (
34+
<>
35+
<Header />
36+
<main className="min-h-screen bg-[var(--background)] pb-20 pt-36 newsprint-texture">
37+
<McpConnectClient locale={locale} />
38+
</main>
39+
<Footer />
40+
</>
41+
);
42+
}
43+
44+
export function generateStaticParams() {
45+
return routing.locales.map((locale) => ({ locale }));
46+
}

app/components/Header.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ export async function Header() {
4242
>
4343
{t("nav.rank")}
4444
</Link>
45+
<Link
46+
href="/mcp"
47+
className="hover:text-[#CC0000] transition-colors"
48+
data-umami-event="navigation_click"
49+
data-umami-event-region="header"
50+
data-umami-event-label="mcp"
51+
>
52+
{t("nav.mcp")}
53+
</Link>
4554
<Link
4655
href="/#community"
4756
className="hover:text-[#CC0000] transition-colors"

dev_docs/mcp_auth_upgrade.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ MCP 的公开 `search` 不需要登录,`publish` 使用 `Authorization: Bearer
99

1010
工具层不判断 token 类型,只使用鉴权层给出的已验证身份和 backend headers。因此 satoken 与 OAuth 可以并行,迁移时不改 MCP tool schema。
1111

12+
## 近期可选优化:sa-token 滑动续期
13+
14+
sa-token 支持在配置层启用基于活动的自动续期(sliding expiration)。如果启用,任意 API 使用都会继续延长当前 30 天窗口,可在 OAuth 上线前大幅减少 MCP 用户重复复制 token 的麻烦。
15+
16+
纯滑动续期的安全代价是:一枚泄露但持续活跃的 token 可能无限期有效,因此建议同时设置绝对最长生命周期。是否启用属于后端配置决策;若采用,必须按 `SECURITY.md` 的安全文化补充成对测试,分别证明正常续期与超过绝对生命周期后拒绝。
17+
1218
## 路线 A:Spring Boot 在 sa-token 上实现 OAuth 2.1
1319

1420
这条路线由现有后端同时承担 Authorization Server 与 Resource Server。用户登录、账号关联、权限判断继续复用当前 sa-token 数据。

dev_docs/mcp_server.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ sa-token 当前有效期是 30 天。网页端暂时没有安全的 OAuth 授权
120120

121121
## 本地开发
122122

123+
面向用户时,推荐直接访问站内 `/mcp` 页面获取各客户端的安装命令与配置。页面会在浏览器挂载后读取当前登录用户的 satoken,并自动填入可发布版本;未登录时只显示占位符和登录提示。
124+
125+
开发者仍可用下面的命令手动连接:
126+
123127
```bash
124128
corepack pnpm check:pnpm-version
125129
BACKEND_URL=http://localhost:8080 corepack pnpm dev

0 commit comments

Comments
 (0)