Files
Arcrun/mcp/src/index.ts
T
Claude 7d9d478baa feat(mcp): OAuth 2.1 server for claude.ai remote connector; close plaintext-namespace bearer hole
在 arcrun-mcp worker 實作 MCP Authorization 規範(OAuth 2.1 + PKCE S256),
讓 claude.ai 遠端 connector 安全登入;並修掉「Bearer 明碼 namespace 直接放行」漏洞。

安全模型
- /authorize 同意頁以 owner secret(CF Secrets MCP_OWNER_SECRET)把關,只有 owner 知道 →
  只知 URL 的人走不完 OAuth、拿不到 token。
- access_token 是 /mcp 唯一接受的 bearer(預設);明碼 namespace 舊路徑移除(步驟 5 直接 401)。

實作 endpoint(掛 worker 根路徑)
- RFC 9728 /.well-known/oauth-protected-resource(+/mcp 變體)+ 401 帶
  WWW-Authenticate: Bearer resource_metadata=...
- RFC 8414 /.well-known/oauth-authorization-server(response_types=code, S256, none)
- RFC 7591 /register(public client,無 secret,無狀態不落地)
- GET/POST /authorize(PKCE S256 + owner-secret 閘 + redirect_uri 白名單)
- POST /token(authorization_code + PKCE 驗證 → access_token 綁定 owner namespace)

儲存鐵律
- authorization code / access token → 短效 KV OAUTH_KV(key 用 SHA-256 hash、帶 TTL、code 一次性)
- owner secret / static token → CF Secrets(非 KV、非明碼 var)
- DCR client / refresh token → 不落地(無狀態 / 不實作,避免長效機密進 KV)

相容決策
- 本機 CLI/GUI/Claude Code → 用真祕密 MCP_STATIC_TOKEN(CF Secret)取代舊明碼 namespace
- 官方 SaaS partner-key 路徑行為不變
- ALLOW_PLAINTEXT_NAMESPACE 逃生門預設關(僅遷移期)

驗證:tsc exit 0;vitest 42/42(oauth 22 + partner-auth 10 改測真實 middleware + 既有 10);
wrangler deploy --dry-run 打包過、OAUTH_KV binding 正確識別。設計文件 mcp/OAUTH.md。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
2026-07-07 03:59:00 +00:00

252 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from "hono";
import { cors } from "hono/cors";
import { Env } from "./types.js";
import { partnerAuthMiddleware } from "./middleware/partner-auth.js";
import { handleMcpRequest } from "./mcp-handler.js";
import { inspectorHtml } from "./pages/inspector.js";
import { kbdbFetch } from "./lib/kbdb-client.js";
import { registerOAuthRoutes } from "./oauth/routes.js";
const _app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>();
// ── OAuth 2.1 server 路由(掛在 worker 根路徑,非 /mcp)──────────────────────────
// well-known / authorize / token / register 必須在 origin 根,claude.ai 遠端 connector 才發現得到。
// 安全模型見 mcp/OAUTH.md。註冊在 basePath 之前,落在同一份共享 router。
registerOAuthRoutes(_app);
const app = _app.basePath('/mcp');
app.use("*", cors({
origin: "*",
allowMethods: ["GET", "POST", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
exposeHeaders: ["Content-Type"],
maxAge: 600,
}));
app.get("/", (c) => c.text("u6u MCP Server is running."));
app.get("/inspector", (c) => {
return c.html(inspectorHtml);
});
// ── GUI 認證端點 ───────────────────────────────────────────────────────────────
// GET /auth/verify — GUI 登入驗證,重用 partnerAuthMiddleware
app.get("/auth/verify", partnerAuthMiddleware, (c) => {
const orgNamespace = c.get("org_namespace");
return c.json({ valid: true, org_namespace: orgNamespace });
});
// ── GUI REST 端點(與 MCP tools 平行) ────────────────────────────────────────
// GET /workflows — 列出 Workflow 清單(GUI 用)
app.get("/workflows", partnerAuthMiddleware, async (c) => {
const orgNamespace = c.get("org_namespace");
try {
const resp = await kbdbFetch(
c.env,
`/records/search?template=workflow_metadata&user_id=${encodeURIComponent(orgNamespace)}`
);
if (!resp.ok) return c.json({ workflows: [] });
const data = await resp.json<{ records: Array<{ id: string; slots?: Record<string, unknown> }> }>();
const workflows = (data.records ?? []).map(r => ({
id: r.id,
name: (r.slots?.display_name as string | undefined) ?? (r.slots?.name as string | undefined) ?? r.id,
last_run: r.slots?.last_run as string | undefined,
status: r.slots?.status as string | undefined,
slots: r.slots,
}));
return c.json({ workflows });
} catch {
return c.json({ workflows: [] });
}
});
// GET /workflows/:id — 取得單一 WorkflowGUI poll 用)
app.get("/workflows/:id", partnerAuthMiddleware, async (c) => {
const id = c.req.param("id") ?? '';
try {
if (!id) return c.json({ error: "Missing id" }, 400);
const resp = await kbdbFetch(c.env, `/records/${encodeURIComponent(id)}`);
if (!resp.ok) return c.json({ error: "Not found" }, 404);
const data = await resp.json<{ id: string; slots?: Record<string, unknown> }>();
return c.json({
id: data.id,
name: (data.slots?.display_name as string | undefined) ?? data.id,
slots: data.slots,
});
} catch {
return c.json({ error: "Internal error" }, 500);
}
});
// POST /action-log — GUI 寫入用戶動作記錄
app.post("/action-log", partnerAuthMiddleware, async (c) => {
const orgNamespace = c.get("org_namespace");
try {
const body = await c.req.json<{
action_type: string;
payload?: Record<string, unknown>;
occurred_at?: string;
}>();
const occurred_at = body.occurred_at ?? new Date().toISOString();
await kbdbFetch(c.env, "/records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
template_id: "tpl-action-log",
user_id: orgNamespace,
slots: {
org_namespace: orgNamespace,
action_type: body.action_type,
payload: JSON.stringify(body.payload ?? {}),
occurred_at,
},
}),
});
return c.json({ ok: true });
} catch {
return c.json({ ok: false }, 500);
}
});
// ── Prototype Pages REST 端點 ─────────────────────────────────────────────────
// GET /prototype-pages — 列出 Prototype Pages
app.get("/prototype-pages", partnerAuthMiddleware, async (c) => {
const orgNamespace = c.get("org_namespace");
try {
const resp = await kbdbFetch(
c.env,
`/records/search?template=tpl-page-block&user_id=${encodeURIComponent(orgNamespace)}`
);
if (!resp.ok) return c.json({ pages: [] });
const data = await resp.json<{ records: Array<{ id: string; slots?: Record<string, unknown> }> }>();
const pages = (data.records ?? []).map(r => ({
id: r.id,
page_name: (r.slots?.page_name as string | undefined) ?? 'Untitled',
components_json: (r.slots?.components_json as string | undefined) ?? '[]',
last_edited_by: (r.slots?.last_edited_by as string | undefined) ?? 'gui',
last_edited_at: (r.slots?.last_edited_at as string | undefined) ?? '',
status: (r.slots?.status as string | undefined) ?? 'draft',
}));
return c.json({ pages });
} catch {
return c.json({ pages: [] });
}
});
// POST /prototype-pages — 建立新 Prototype Page
app.post("/prototype-pages", partnerAuthMiddleware, async (c) => {
const orgNamespace = c.get("org_namespace");
try {
const body = await c.req.json<{ page_name?: string }>();
const page_name = body.page_name ?? 'Untitled';
const now = new Date().toISOString();
const resp = await kbdbFetch(c.env, "/records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
template_id: "tpl-page-block",
user_id: orgNamespace,
slots: {
page_name,
org_namespace: orgNamespace,
components_json: '[]',
last_edited_by: 'gui',
last_edited_at: now,
status: 'draft',
},
}),
});
if (!resp.ok) return c.json({ error: "Failed to create" }, 500);
const data = await resp.json<{ id: string; slots?: Record<string, unknown> }>();
return c.json({
id: data.id,
page_name,
components_json: '[]',
last_edited_by: 'gui',
last_edited_at: now,
status: 'draft',
}, 201);
} catch {
return c.json({ error: "Internal error" }, 500);
}
});
// GET /prototype-pages/:id — 取得單一 Prototype Page
app.get("/prototype-pages/:id", partnerAuthMiddleware, async (c) => {
const id = c.req.param("id") ?? '';
try {
if (!id) return c.json({ error: "Missing id" }, 400);
const resp = await kbdbFetch(c.env, `/records/${encodeURIComponent(id)}`);
if (!resp.ok) return c.json({ error: "Not found" }, 404);
const data = await resp.json<{ id: string; slots?: Record<string, unknown> }>();
return c.json({
id: data.id,
page_name: (data.slots?.page_name as string | undefined) ?? 'Untitled',
components_json: (data.slots?.components_json as string | undefined) ?? '[]',
last_edited_by: (data.slots?.last_edited_by as string | undefined) ?? 'gui',
last_edited_at: (data.slots?.last_edited_at as string | undefined) ?? '',
status: (data.slots?.status as string | undefined) ?? 'draft',
});
} catch {
return c.json({ error: "Internal error" }, 500);
}
});
// PUT /prototype-pages/:id — 儲存 Prototype Page
app.put("/prototype-pages/:id", partnerAuthMiddleware, async (c) => {
const id = c.req.param("id") ?? '';
try {
if (!id) return c.json({ error: "Missing id" }, 400);
const body = await c.req.json<{
components_json?: string;
page_name?: string;
}>();
const now = new Date().toISOString();
const slots: Record<string, unknown> = {
last_edited_by: 'gui',
last_edited_at: now,
};
if (body.components_json !== undefined) slots.components_json = body.components_json;
if (body.page_name !== undefined) slots.page_name = body.page_name;
const resp = await kbdbFetch(c.env, `/records/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slots }),
});
if (!resp.ok) return c.json({ error: "Failed to save" }, 500);
return c.json({ ok: true });
} catch {
return c.json({ error: "Internal error" }, 500);
}
});
// ── MCP 端點 ──────────────────────────────────────────────────────────────────
app.options("/mcp", (c) => {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
});
app.post("/", partnerAuthMiddleware, async (c) => {
const orgNamespace = c.get("org_namespace");
const partnerToken = c.get("partner_token");
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken);
});
// 輸出根 app_app):與 basePath('/mcp') 的 app 共享同一份 router,故 OAuth 根路由與
// /mcp 路由都能被分派。(若輸出 app 則根路徑的 well-known 分派行為依賴 basePath 細節,改輸出 _app 明確。)
export default _app;