Files
wmailhub/console/src/routes/mcp.ts
T
bigcode 421cfb2f04 feat: v1.1.0 账号化重构 + 三级 Webhook + 设置中心 + Vite 工具链
- 账号化:节点更名账号,/nodes -> /accounts;添加账号直达 Cloudflare OAuth,自动创建账号并同步账号下全部域名(catch-all 邮箱随同步创建)
- 域名:移除手工添加;点击域名『设置 Email Workers』= 部署账号级 Email Worker + Email Routing + catch-all;支持按账号筛选
- Webhook 三级模型:邮箱 -> 域名 -> 全局,收信取第一个命中者;支持自定义请求头与 key(默认 Authorization: Bearer <key>,兼做 X-WmailHub-Signature 签名);独立 webhooks 实体与 /webhooks、/keys 页面删除,Key 管理收进设置模态窗口
- 设置中心:全局 Webhook / 默认 Node Ingress 地址 / 修改密码 / API & MCP Keys(生成、撤销、删除)
- UI:Header 标题副标题紧凑化(修复溢出),LOGO 加版本号 tag(v1.1.0 取自 package.json),各列表页搜索框
- 工具链:前端构建 Bun.build -> Vite(vite 8 + @vitejs/plugin-react)
- 后端:/api/settings、POST /api/domains/:id/setup、0002 迁移(投递行固化三级目标配置);MCP 去 list_webhooks(5 工具);迁移执行器加 _migrations 记录
- 测试:44/44(新增 settings 套件、webhook 三级优先级/SSRF/签名);UI 冒烟 11/11
2026-09-30 11:37:38 +08:00

241 lines
8.7 KiB
TypeScript

/**
* /api/v1/mcp :MCP over Streamable HTTP(JSON-RPC 2.0),MCP Key(wmh_)认证。
*
* 工具:list_mailboxes / list_emails / get_email / create_mailbox / delete_mailbox
* 权限:基于 Console credential(docs/09 Phase 9);邮件强制检查未过期。
* 实现说明:POST JSON-RPC -> application/json 响应(Streamable HTTP 允许服务器
* 返回普通 JSON);GET 返回 405(无 SSE 流)。
*/
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireApiKey } from "../middleware.ts";
import { upsertMailbox } from "../services/provision.ts";
import { APP_VERSION } from "../version.ts";
import { nowIso, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 成员变量: MCP 协议版本 **/
const MCP_PROTOCOL_VERSION = "2025-06-18";
/** # 成员变量: MCP 工具定义(JSON Schema) **/
const TOOLS = [
{
name: "list_mailboxes",
description: "列出所有邮箱(含域名、catch-all 标记、保留秒数)",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
{
name: "list_emails",
description: "列出邮件(仅未过期),可按 mailbox_id 过滤",
inputSchema: {
type: "object",
properties: {
mailbox_id: { type: "string", description: "可选,按邮箱过滤" },
limit: { type: "number", description: "可选,默认 20,最大 100" },
},
additionalProperties: false,
},
},
{
name: "get_email",
description: "获取单封邮件详情(正文与附件元数据);过期邮件返回 410 语义错误",
inputSchema: {
type: "object",
properties: { id: { type: "string", description: "邮件 ID(msg_...)" } },
required: ["id"],
additionalProperties: false,
},
},
{
name: "create_mailbox",
description: "在指定域名下创建邮箱",
inputSchema: {
type: "object",
properties: {
domain_id: { type: "string", description: "域名 ID(dom_...)" },
address: { type: "string", description: "邮箱地址或本地部分" },
retention_seconds: { type: "number", description: "可选保留秒数,默认 86400(1 天)" },
},
required: ["domain_id", "address"],
additionalProperties: false,
},
},
{
name: "delete_mailbox",
description: "删除邮箱",
inputSchema: {
type: "object",
properties: { id: { type: "string", description: "邮箱 ID(mbx_...)" } },
required: ["id"],
additionalProperties: false,
},
},
] as const;
/** # 函数
* 装配 MCP 路由
*/
export function mcpRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireApiKey("mcp"));
app.post("/", async (c) => {
const rpc = await readJson<JsonRpcRequest>(c.req.raw);
if (typeof rpc.jsonrpc !== "string" || typeof rpc.method !== "string") {
return c.json(jsonRpcError(null, -32600, "invalid request"), 400);
}
const id = rpc.id ?? null;
try {
switch (rpc.method) {
case "initialize":
return c.json({
jsonrpc: "2.0",
id,
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: "wmailhub", version: APP_VERSION },
},
});
case "notifications/initialized":
return new Response(null, { status: 202 });
case "ping":
return c.json({ jsonrpc: "2.0", id, result: {} });
case "tools/list":
return c.json({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
case "tools/call": {
const params = (rpc.params ?? {}) as { name?: string; arguments?: Record<string, unknown> };
const result = await callTool(c.env, params.name ?? "", params.arguments ?? {});
return c.json({ jsonrpc: "2.0", id, result });
}
default:
return c.json(jsonRpcError(id, -32601, `method not found: ${rpc.method}`), 200);
}
} catch (err) {
const message = err instanceof McpToolError ? err.message : `internal error: ${err instanceof Error ? err.message : String(err)}`;
const code = err instanceof McpToolError ? err.rpcCode : -32603;
return c.json(jsonRpcError(id, code, message), 200);
}
});
app.get("/", (c) => c.json({ error: "POST JSON-RPC requests only" }, 405));
return app;
}
/** # 成员变量: JSON-RPC 请求结构 **/
interface JsonRpcRequest {
jsonrpc: string;
id?: string | number | null;
method: string;
params?: Record<string, unknown>;
}
/** # 函数
* 构造 JSON-RPC error 响应
*/
function jsonRpcError(id: string | number | null, code: number, message: string) {
return { jsonrpc: "2.0", id, error: { code, message } };
}
/** # 成员变量: 工具层错误(映射到 JSON-RPC error code) **/
class McpToolError extends Error {
constructor(
message: string,
public rpcCode: number,
) {
super(message);
}
}
/** # 函数
* 工具调用分发
*/
async function callTool(
env: Env,
name: string,
args: Record<string, unknown>,
): Promise<{ content: { type: string; text: string }[]; isError?: boolean }> {
const data = await dispatchTool(env, name, args);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
async function dispatchTool(env: Env, name: string, args: Record<string, unknown>): Promise<unknown> {
switch (name) {
case "list_mailboxes": {
const rows = await env.DB.prepare(
`SELECT m.id, m.address, m.status, m.is_catch_all, m.retention_seconds, d.name AS domain_name
FROM mailboxes m JOIN domains d ON d.id = m.domain_id ORDER BY m.created_at DESC`,
).all();
return { mailboxes: rows.results };
}
case "list_emails": {
const limit = Math.min(Math.max(Number(args.limit) || 20, 1), 100);
const mailboxId = typeof args.mailbox_id === "string" ? args.mailbox_id : null;
const conds = ["e.expires_at > ?"];
const binds: unknown[] = [nowIso()];
if (mailboxId) {
conds.push("e.mailbox_id = ?");
binds.push(mailboxId);
}
const rows = await env.DB.prepare(
`SELECT e.id, e.mailbox_id, m.address AS mailbox_address, e.from_address, e.subject,
e.size, e.received_at, e.expires_at
FROM emails e LEFT JOIN mailboxes m ON m.id = e.mailbox_id
WHERE ${conds.join(" AND ")} ORDER BY e.received_at DESC LIMIT ?`,
)
.bind(...binds, limit)
.all();
return { emails: rows.results };
}
case "get_email": {
const id = String(args.id ?? "");
if (!id) throw new McpToolError("id required", -32602);
const email = await env.DB.prepare(
`SELECT e.*, m.address AS mailbox_address FROM emails e
LEFT JOIN mailboxes m ON m.id = e.mailbox_id WHERE e.id = ?`,
)
.bind(id)
.first();
if (!email) throw new McpToolError("email not found", -32002);
if ((email as { expires_at: string }).expires_at <= nowIso()) {
throw new McpToolError("email expired", -32002);
}
const atts = await env.DB.prepare(
"SELECT id, filename, content_type, size, content_id FROM email_attachments WHERE email_id = ?",
)
.bind(id)
.all();
return { email, attachments: atts.results };
}
case "create_mailbox": {
const domainId = String(args.domain_id ?? "");
const address = String(args.address ?? "").trim().toLowerCase();
if (!domainId || !address) throw new McpToolError("domain_id and address required", -32602);
const domain = await env.DB.prepare("SELECT id, name FROM domains WHERE id = ?")
.bind(domainId)
.first<{ id: string; name: string }>();
if (!domain) throw new McpToolError("domain not found", -32002);
let full = address;
if (!full.includes("@")) full = `${full}@${domain.name}`;
else if (!full.endsWith(`@${domain.name}`)) throw new McpToolError(`address must be @${domain.name}`, -32602);
const exists = await env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?").bind(full).first();
if (exists) throw new McpToolError("mailbox already exists", -32002);
const retention = args.retention_seconds ? Number(args.retention_seconds) : undefined;
const id = await upsertMailbox(env, { domainId, address: full, retentionSeconds: retention });
return { id, address: full };
}
case "delete_mailbox": {
const id = String(args.id ?? "");
if (!id) throw new McpToolError("id required", -32602);
const res = await env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
if (!res.meta?.changes) throw new McpToolError("mailbox not found", -32002);
return { ok: true };
}
default:
throw new McpToolError(`unknown tool: ${name}`, -32601);
}
}