Skip to content

Commit a0cb515

Browse files
fylornclaude
andcommitted
feat(mcp): per-user OAuth pitch — comparison section + v0.3.0 changelog
Headline the v0.3.0 MCP work on the landing page: a new MCPAdvantage section (8-row table vs SaaS gateways and DIY mcp-proxy + four "what this unlocks" cards), strengthened MCP module bullets naming Linear / GitHub / Slack, and a Hero stat swap to "Per-user MCP OAuth". Bilingual v0.3.0 changelog entry covers per-user credentials, one-paste DCR, MCP Store, three-tier subject resolution, Test Connection, scoped cache, and the security review hardening. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 06bfb43 commit a0cb515

7 files changed

Lines changed: 366 additions & 14 deletions

File tree

src/components/SiteHeader.astro

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const homeAnchor = (hash: string) => (isHome ? hash : `${homePath}${hash}`);
2121
const links = [
2222
{ href: homeAnchor("#how"), label: s.nav.how },
2323
{ href: homeAnchor("#features"), label: s.nav.features },
24+
{ href: homeAnchor("#mcp"), label: s.nav.mcp },
2425
{ href: homeAnchor("#dashboard"), label: s.nav.console },
2526
{ href: homeAnchor("#pricing"), label: s.nav.pricing },
2627
{ href: localePath(lang, "/docs"), label: s.nav.docs },
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
---
2+
import { getLang, t } from "~/i18n";
3+
4+
const s = t(getLang(Astro)).mcpAdvantage;
5+
6+
// Map raw cell values to (label, treatment) so the table renders consistently
7+
// across en / zh-CN. Treatment drives color: positive = green, neutral = amber,
8+
// negative = dim red.
9+
type Treatment = "positive" | "neutral" | "negative";
10+
const cellMap: Record<string, { label: string; treatment: Treatment }> = {
11+
yes: { label: "", treatment: "positive" },
12+
no: { label: "", treatment: "negative" },
13+
shared: { label: s.legendShared ?? "shared account", treatment: "negative" },
14+
partial: { label: s.legendPartial ?? "partial", treatment: "neutral" },
15+
limited: { label: s.legendLimited ?? "limited", treatment: "neutral" },
16+
"hosted-only": { label: s.legendHostedOnly ?? "hosted-only", treatment: "neutral" },
17+
"n/a": { label: "n/a", treatment: "negative" },
18+
saas: { label: s.legendSaas ?? "SaaS-only", treatment: "neutral" },
19+
varies: { label: s.legendVaries ?? "varies", treatment: "neutral" },
20+
english: { label: s.legendEnglish ?? "English-only", treatment: "neutral" },
21+
proprietary: { label: s.legendProprietary ?? "proprietary",treatment: "neutral" },
22+
oss: { label: s.legendOss ?? "OSS", treatment: "neutral" },
23+
};
24+
25+
function cell(raw: string) {
26+
return cellMap[raw] ?? { label: raw, treatment: "neutral" as Treatment };
27+
}
28+
29+
const treatmentClass: Record<Treatment, string> = {
30+
positive: "text-[var(--color-brand-1)]",
31+
neutral: "text-amber-300/80",
32+
negative: "text-[var(--color-dim)]",
33+
};
34+
---
35+
36+
<section id="mcp" class="py-20 sm:py-28 border-t border-white/5 relative overflow-hidden">
37+
<!-- Subtle radial accent so the section reads as the marquee MCP pitch -->
38+
<div
39+
class="pointer-events-none absolute inset-0 -z-0"
40+
aria-hidden="true"
41+
style="background: radial-gradient(60% 50% at 80% 0%, rgba(61, 219, 217, 0.07), transparent 60%), radial-gradient(50% 40% at 10% 100%, rgba(168, 85, 247, 0.06), transparent 60%);"
42+
></div>
43+
44+
<div class="container-page relative z-10">
45+
<div class="max-w-3xl">
46+
<div class="text-xs uppercase tracking-[0.18em] text-[var(--color-brand-1)] font-medium mb-4">
47+
{s.eyebrow}
48+
</div>
49+
<h2 class="text-3xl sm:text-4xl md:text-5xl font-semibold tracking-tight">
50+
{s.title}<span class="text-gradient">{s.titleHighlight}</span>.
51+
</h2>
52+
<p class="mt-4 sm:mt-5 text-base sm:text-lg text-[var(--color-muted)] leading-relaxed">
53+
{s.sub}
54+
</p>
55+
</div>
56+
57+
<!-- Comparison table -->
58+
<div class="mt-14">
59+
<div class="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)] mb-4">
60+
{s.tableTitle}
61+
</div>
62+
63+
<div class="rounded-2xl border border-white/10 bg-[var(--color-surface)]/60 overflow-hidden">
64+
<div class="overflow-x-auto">
65+
<table class="w-full text-sm min-w-[720px]">
66+
<thead>
67+
<tr class="border-b border-white/10 bg-white/[0.02]">
68+
{s.columns.map((col, i) => (
69+
<th
70+
scope="col"
71+
class:list={[
72+
"text-left px-5 py-4 text-xs uppercase tracking-[0.14em] font-medium",
73+
i === 0 ? "text-[var(--color-muted)]" : "",
74+
i === 1 ? "text-white" : "",
75+
i > 1 ? "text-[var(--color-dim)]" : "",
76+
]}
77+
>
78+
{i === 1 ? (
79+
<span class="inline-flex items-center gap-1.5">
80+
<span class="w-1.5 h-1.5 rounded-full bg-[var(--color-brand-1)] shadow-[0_0_8px_var(--color-brand-1)]" aria-hidden="true"></span>
81+
{col}
82+
</span>
83+
) : (
84+
col
85+
)}
86+
</th>
87+
))}
88+
</tr>
89+
</thead>
90+
<tbody>
91+
{s.rows.map((row, rIdx) => (
92+
<tr class:list={["border-b border-white/5 last:border-b-0", rIdx % 2 === 1 ? "bg-white/[0.015]" : ""]}>
93+
<td class="px-5 py-3.5 text-[var(--color-muted)]">{row.label}</td>
94+
{row.values.map((raw, cIdx) => {
95+
const c = cell(raw);
96+
return (
97+
<td
98+
class:list={[
99+
"px-5 py-3.5 font-mono text-[13px] tabular-nums",
100+
treatmentClass[c.treatment],
101+
cIdx === 0 ? "font-semibold" : "",
102+
]}
103+
>
104+
{c.label}
105+
</td>
106+
);
107+
})}
108+
</tr>
109+
))}
110+
</tbody>
111+
</table>
112+
</div>
113+
</div>
114+
115+
<p class="mt-3 text-xs text-[var(--color-dim)]">{s.tableNote}</p>
116+
</div>
117+
118+
<!-- Differentiator cards -->
119+
<div class="mt-14 sm:mt-16">
120+
<div class="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)] mb-4">
121+
{s.cardsTitle}
122+
</div>
123+
<div class="grid gap-4 sm:gap-5 sm:grid-cols-2">
124+
{s.cards.map((card, i) => (
125+
<div
126+
class="mcp-card group relative rounded-2xl border border-white/10 bg-[var(--color-surface)]/60 p-6 hover:border-white/25 transition-colors overflow-hidden"
127+
style={`--mcp-delay:${i * 80}ms`}
128+
>
129+
<div class="flex items-start justify-between mb-4">
130+
<span class="font-mono text-[11px] tracking-[0.18em] text-[var(--color-brand-1)]">
131+
0{i + 1}
132+
</span>
133+
<span class="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--color-dim)]">
134+
ThinkWatch
135+
</span>
136+
</div>
137+
<h3 class="text-lg font-semibold tracking-tight mb-2">{card.title}</h3>
138+
<p class="text-sm text-[var(--color-muted)] leading-relaxed">{card.body}</p>
139+
<div class="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-cyan-400/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
140+
</div>
141+
))}
142+
</div>
143+
</div>
144+
</div>
145+
</section>
146+
147+
<style>
148+
.mcp-card {
149+
opacity: 0;
150+
transform: translateY(14px);
151+
}
152+
.mcp-card.is-visible {
153+
animation: mcpFadeUp 600ms cubic-bezier(.2,.7,.25,1) both;
154+
animation-delay: var(--mcp-delay, 0ms);
155+
}
156+
@keyframes mcpFadeUp {
157+
from { opacity: 0; transform: translateY(14px); }
158+
to { opacity: 1; transform: translateY(0); }
159+
}
160+
@media (prefers-reduced-motion: reduce) {
161+
.mcp-card { opacity: 1; transform: none; animation: none !important; }
162+
}
163+
</style>
164+
165+
<script>
166+
const cards = document.querySelectorAll(".mcp-card");
167+
if (cards.length && "IntersectionObserver" in window) {
168+
const obs = new IntersectionObserver(
169+
(entries) => {
170+
for (const entry of entries) {
171+
if (entry.isIntersecting) {
172+
entry.target.classList.add("is-visible");
173+
obs.unobserve(entry.target);
174+
}
175+
}
176+
},
177+
{ threshold: 0.15 }
178+
);
179+
cards.forEach((c) => obs.observe(c));
180+
} else {
181+
cards.forEach((c) => c.classList.add("is-visible"));
182+
}
183+
</script>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
version: "0.3.0"
3+
date: 2026-05-08
4+
type: feature
5+
title: MCP, the per-user way
6+
highlights:
7+
- Per-user upstream credentials — every developer authenticates as themselves to GitHub, Linear, Slack
8+
- One-paste OAuth onboarding via Dynamic Client Registration
9+
- MCP Store with bilingual templates (Linear OAuth seeded out of the box)
10+
- Step-by-step registration wizard + auth-mode-aware edit form
11+
- Three-tier upstream subject resolution (JWT + userinfo + discovery)
12+
- Per-credential Test Connection on /connections
13+
- Per-user tool catalogs — different users see different tools based on upstream permissions
14+
- MCP response cache scoped per (user, account_label) — no cross-user leakage
15+
- Security hardening from review — SSRF, cache, rate limits, audit
16+
---
17+
18+
## Per-user upstream credentials
19+
20+
The MCP gateway no longer pretends every user is the same upstream service account. Each developer authenticates as themselves — via OAuth or PAT — to GitHub, Linear, Slack, and any other MCP-enabled service. The upstream audit trail finally works end-to-end: tickets are assigned to real people, GitHub issues are created by the engineer who actually filed them. Per-key account overrides let one user maintain multiple identities (personal + work GitHub) and pick which one a given API key uses.
21+
22+
## One-paste OAuth onboarding
23+
24+
Paste an MCP server URL into the registration wizard. ThinkWatch handles **Dynamic Client Registration** with the upstream OAuth server, runs the auth probe, captures the OAuth client credentials at install time, and walks you through the consent screen. 401/403 from anonymous probes is correctly classified as `auth_required` (with an amber status indicator on the catalog tile) rather than a hard failure, so partially-protected servers register cleanly.
25+
26+
## MCP Store
27+
28+
A bilingual template registry is now built into the gateway. Templates ship with sensible defaults, the necessary OAuth scopes, and end-user-facing notes. The Linear OAuth template is seeded out of the box; more popular services follow. **Display labels** disambiguate multi-install templates so a personal GitHub install and a work GitHub install appear as distinct tiles instead of two indistinguishable entries.
29+
30+
## Step-by-step registration wizard
31+
32+
MCP server registration was reworked into a guided wizard with auth-mode-aware screens. The edit form refuses to save fields that don't belong to the current auth mode, and tool-call / install errors are now surfaced in plain language with actionable next steps instead of raw JSON-RPC error codes.
33+
34+
## Three-tier upstream subject resolution
35+
36+
When an upstream MCP server uses OAuth, ThinkWatch resolves the upstream user identity through a three-tier strategy: parse the JWT if present, fall back to the OAuth userinfo endpoint, fall back again to issuer discovery. The resolved subject becomes the cache and audit key, so two users who share an MCP server stay strictly separated downstream.
37+
38+
## Per-credential Test Connection
39+
40+
The `/connections` page surfaces a per-credential **Test Connection** button. Verify your OAuth/PAT actually works against the upstream server before committing to it; the result is structured (auth_ok / auth_required / unreachable / tool_call_ok) rather than a single green/red dot, so you know exactly which step is broken.
41+
42+
## Security hardening
43+
44+
Following an internal security review, the MCP path now has SSRF protection on probe URLs, the response cache is scoped per `(user, account_label)` so OAuth/PAT data never leaks across users, rate limits are applied at every gateway hop, audit records are emitted on tool-call boundaries, and admin foot-gun guards prevent the most common misconfigurations (verifying static tokens at paste time, blocking obviously-wrong credential combinations).
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
version: "0.3.0"
3+
date: 2026-05-08
4+
type: feature
5+
title: MCP,按用户的方式
6+
highlights:
7+
- 按用户的上游凭证——每位开发者以自己的身份连接 GitHub、Linear、Slack
8+
- Dynamic Client Registration 一键 OAuth 接入
9+
- MCP Store 双语模板市场(Linear OAuth 模板开箱预置)
10+
- 分步注册向导 + 鉴权模式感知的编辑表单
11+
- 三层上游身份解析(JWT + userinfo + discovery)
12+
- /connections 页面提供逐凭证 Test Connection
13+
- 按用户的工具目录——不同用户根据上游权限看到不同工具集
14+
- MCP 响应缓存按 (user, account_label) 隔离——无跨用户串扰
15+
- 安全评审加固 —— SSRF、缓存隔离、限流、审计
16+
---
17+
18+
## 按用户的上游凭证
19+
20+
MCP 网关不再假装所有用户都是同一个上游服务账号。每位开发者以自己的身份——通过 OAuth 或 PAT——连接 GitHub、Linear、Slack 以及任何启用了 MCP 的服务。上游审计轨迹终于端到端贯通:工单分配给真实的人、GitHub Issue 由实际提交者创建。逐密钥账号覆盖(per-key account override)让同一用户可同时维护多个身份(个人 + 工作 GitHub),并指定某个 API 密钥使用哪一个。
21+
22+
## 一键 OAuth 接入
23+
24+
在注册向导中粘贴 MCP 服务器 URL,ThinkWatch 自动与上游 OAuth 服务器完成 **Dynamic Client Registration**,运行鉴权探测,在安装时收集 OAuth 客户端凭证,并引导你完成授权页面。匿名探测的 401/403 被正确识别为 `auth_required`(在目录卡片上显示琥珀色状态指示),而不是硬性失败——部分受保护的服务器也能干净地注册。
25+
26+
## MCP Store
27+
28+
网关内置双语模板注册表。模板自带合理默认值、所需 OAuth scope,以及面向终端用户的说明。Linear OAuth 模板开箱预置,更多主流服务陆续加入。**Display Label** 让多次安装的模板区分清晰:个人 GitHub 安装与工作 GitHub 安装显示为独立卡片,而不是两个无法分辨的条目。
29+
30+
## 分步注册向导
31+
32+
MCP 服务器注册重构为分步引导式向导,鉴权模式不同呈现不同界面。编辑表单拒绝保存与当前鉴权模式不匹配的字段;工具调用与安装错误以易懂的语言展示并给出可执行的下一步建议,而不是原始 JSON-RPC 错误码。
33+
34+
## 三层上游身份解析
35+
36+
当上游 MCP 服务器使用 OAuth 时,ThinkWatch 通过三层策略解析上游用户身份:先解析 JWT,回落到 OAuth userinfo 端点,再回落到 issuer discovery。解析得到的 subject 作为缓存和审计的关键键值,让共用同一个 MCP 服务器的两位用户在下游严格分离。
37+
38+
## 逐凭证 Test Connection
39+
40+
`/connections` 页面为每个凭证提供 **Test Connection** 按钮。在正式提交前先验证你的 OAuth/PAT 是否真的能连上上游服务器;结果是结构化的(auth_ok / auth_required / unreachable / tool_call_ok),不再是单一红绿点——你能精确知道哪一步出了问题。
41+
42+
## 安全加固
43+
44+
经过一次内部安全评审,MCP 路径新增:探测 URL 的 SSRF 防护;响应缓存按 `(user, account_label)` 隔离,OAuth/PAT 数据绝不跨用户泄露;每一跳网关都启用限流;工具调用边界发出结构化审计;管理员防误操作护栏(粘贴时验证静态 Token、阻止明显错误的凭证组合)。

0 commit comments

Comments
 (0)