7d9d478baa
在 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
252 lines
9.5 KiB
TypeScript
252 lines
9.5 KiB
TypeScript
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 — 取得單一 Workflow(GUI 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;
|