/** * /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(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 }; 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; } /** # 函数 * 构造 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, ): 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): Promise { 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); } }