Files
Arcrun/mcp/src/index.ts
T
Leo 1c630ecfd4 docs(mcp): /health 註解誠實界定——404 只代表比本版舊,不等於舊世代
實測:leo21c 與 geek6688 的 /health 都回 404,但 geek6688 是新世代(email+password)。
原註解會讓下一個人拿 404 去判世代而誤判。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 09:51:36 +00:00

270 lines
11 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);
// ── GET /health — 讓「這台跑的是哪一版 MCP」用一條 curl 看得到 ─────────────────────
// 為什麼要有:cypher-executor 早就有 /healthbundle_version + auth_store 探針),
// arcrun-mcp 沒有 ⇒ 要判斷某台實例的 MCP 是哪一代認證,只能打 /authorize 剖 HTML 數欄位
// ops-facts 2026-08-10 的土法)。那個判準脆弱又難教。
//
// ⚠️ 誠實界定(別把它當世代判準用過頭):本端點是**這個 commit 之後才有的**,所以
// 「/health 回 404」只代表「比本版舊」,**不代表就是 owner_secret 世代**——
// 現階段判世代仍要看 /authorize 的欄位(一個 owner_secret =舊;email+password =新)。
// 等這版推到各實例之後,`auth` 欄位才會變成一眼可讀的世代判準。
// 不需認證、不吐任何機密;MCP_BUILD 是部署標記,由各實例 toml [vars] 帶入。
_app.get("/health", (c) => c.json({
ok: true,
service: "arcrun-mcp",
auth: "portal-login",
build: c.env.MCP_BUILD ?? "unknown",
oauth_kv: c.env.OAUTH_KV ? "present" : "missing",
}));
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;