mirror of
https://github.com/beilunyang/moemail.git
synced 2026-07-10 23:21:21 +08:00
feat(cli,mcp): extract @moemail/core and add MCP server; release 1.0.0
Extract the HTTP client and config into a new @moemail/core package shared by the CLI and a new @moemail/mcp server, so both frontends talk to the same MoeMail API through one code path. - core: api client (now throws typed ConfigError/AuthError/PermissionError/ QuotaError instead of process.exit), config, msToIso, and a transport- agnostic pollForNewMessage helper. - cli: consume @moemail/core; route command errors through a shared fail() that preserves exit codes (config/auth = 2, else = 1). Bump to 1.0.0. - mcp: new stdio MCP server exposing 8 tools (create/list/read/wait/send/ delete); wait_for_email is bounded and returns a timeout status to retry. Configured via MOEMAIL_API_KEY / MOEMAIL_API_URL env. Release 1.0.0. Docs: - Fix packages/cli/README.md (config set, send --content not --body, full flag table). - Add MCP section to both root READMEs; complete the CLI command list (send, list, message-level delete). - SKILL.md: --json works before or after the subcommand. - Ignore bun.lock in package gitignores. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2
packages/mcp/.gitignore
vendored
Normal file
2
packages/mcp/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
54
packages/mcp/README.md
Normal file
54
packages/mcp/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# @moemail/mcp
|
||||
|
||||
MCP (Model Context Protocol) server for [MoeMail](https://moemail.app) — gives any
|
||||
MCP-capable agent (Claude Desktop, Cursor, Cline, …) native tools for temporary
|
||||
email: create a mailbox, wait for a verification email, read it, send, and clean up.
|
||||
|
||||
It shares the same HTTP client and config as `@moemail/cli` via `@moemail/core`, so
|
||||
it talks to the exact same MoeMail API (authenticated with an `X-API-Key`).
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `create_email` | Create a temporary mailbox (`expiry`: `1h` / `24h` / `3d` / `permanent`) |
|
||||
| `list_emails` | List mailboxes owned by the API key |
|
||||
| `list_messages` | List messages in a mailbox |
|
||||
| `read_message` | Read full text/HTML of a message |
|
||||
| `wait_for_email` | Poll for a new message (bounded, max 90s; returns `status: "timeout"` to retry) |
|
||||
| `send_email` | Send from a temporary address (needs send permission) |
|
||||
| `delete_email` | Delete a mailbox |
|
||||
| `delete_message` | Delete a single message |
|
||||
|
||||
## Configuration
|
||||
|
||||
The server reads credentials from environment variables:
|
||||
|
||||
- `MOEMAIL_API_KEY` (required) — your MoeMail API key
|
||||
- `MOEMAIL_API_URL` (optional) — defaults to `https://moemail.app`
|
||||
|
||||
## Usage
|
||||
|
||||
Add to your MCP client config (e.g. Claude Desktop `claude_desktop_config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"moemail": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@moemail/mcp"],
|
||||
"env": {
|
||||
"MOEMAIL_API_KEY": "mk_xxx",
|
||||
"MOEMAIL_API_URL": "https://moemail.app"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- API keys authenticate against `/api/emails*` and `/api/config*` — the same surface
|
||||
the CLI uses.
|
||||
- Authentication, permission, and rate-limit failures from the server are surfaced as
|
||||
descriptive tool errors rather than generic failures.
|
||||
24
packages/mcp/package.json
Normal file
24
packages/mcp/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@moemail/mcp",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for MoeMail temporary email service",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"moemail-mcp": "dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun build ./src/index.ts --outdir ./dist --target=node",
|
||||
"dev": "bun run ./src/index.ts"
|
||||
},
|
||||
"files": ["dist"],
|
||||
"keywords": ["email", "temporary", "mcp", "agent", "ai"],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@moemail/core": "file:../core",
|
||||
"@types/node": "^20.0.0"
|
||||
}
|
||||
}
|
||||
23
packages/mcp/src/index.ts
Normal file
23
packages/mcp/src/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { registerTools } from "./tools.js";
|
||||
|
||||
async function main() {
|
||||
const server = new McpServer({
|
||||
name: "moemail",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
registerTools(server);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
// stdout is reserved for the MCP protocol; log to stderr only.
|
||||
console.error("MoeMail MCP server running on stdio");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
265
packages/mcp/src/tools.ts
Normal file
265
packages/mcp/src/tools.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
api,
|
||||
AuthError,
|
||||
ConfigError,
|
||||
msToIso,
|
||||
PermissionError,
|
||||
pollForNewMessage,
|
||||
QuotaError,
|
||||
} from "@moemail/core";
|
||||
|
||||
const EXPIRY_MAP: Record<string, number> = {
|
||||
"1h": 3600000,
|
||||
"24h": 86400000,
|
||||
"3d": 259200000,
|
||||
permanent: 0,
|
||||
};
|
||||
|
||||
/** Cap a tool-level wait well below typical MCP client timeouts. */
|
||||
const WAIT_MAX_SEC = 90;
|
||||
const WAIT_DEFAULT_SEC = 60;
|
||||
|
||||
function errorText(e: unknown): string {
|
||||
if (e instanceof QuotaError) {
|
||||
const quota =
|
||||
e.monthlyLimit != null ? ` (used ${e.monthlyUsed ?? "?"}/${e.monthlyLimit} this month)` : "";
|
||||
return `Monthly API quota exceeded${quota}: ${e.message}`;
|
||||
}
|
||||
if (e instanceof PermissionError) return `Permission denied: ${e.message}`;
|
||||
if (e instanceof AuthError) return `Authentication failed: ${e.message}`;
|
||||
if (e instanceof ConfigError) return `Configuration error: ${e.message}`;
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
function ok(data: unknown): CallToolResult {
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
/** Run a tool body, mapping thrown core errors to an isError result. */
|
||||
async function run(fn: () => Promise<CallToolResult>): Promise<CallToolResult> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
return { content: [{ type: "text", text: errorText(e) }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTools(server: McpServer): void {
|
||||
server.registerTool(
|
||||
"create_email",
|
||||
{
|
||||
title: "Create temporary email",
|
||||
description:
|
||||
"Create a temporary email address. Returns its id and address. Use the id for all later operations.",
|
||||
inputSchema: {
|
||||
name: z.string().optional().describe("Email prefix (random if omitted)"),
|
||||
domain: z.string().optional().describe("Email domain (first configured domain if omitted)"),
|
||||
expiry: z
|
||||
.enum(["1h", "24h", "3d", "permanent"])
|
||||
.default("1h")
|
||||
.describe("Lifetime of the mailbox"),
|
||||
},
|
||||
},
|
||||
({ name, domain, expiry }) =>
|
||||
run(async () => {
|
||||
const expiryTime = EXPIRY_MAP[expiry];
|
||||
|
||||
let resolvedDomain: string;
|
||||
if (domain) {
|
||||
resolvedDomain = domain;
|
||||
} else {
|
||||
const config = (await api.getConfig()) as any;
|
||||
const domains: string[] =
|
||||
config.emailDomains?.split(",").map((d: string) => d.trim()) ?? [];
|
||||
if (!domains.length) {
|
||||
throw new Error("No email domains configured on server. Pass `domain` explicitly.");
|
||||
}
|
||||
resolvedDomain = domains[0];
|
||||
}
|
||||
|
||||
const result = (await api.createEmail({ name, expiryTime, domain: resolvedDomain })) as any;
|
||||
const expiresAt = expiryTime === 0 ? null : msToIso(Date.now() + expiryTime);
|
||||
return ok({ id: result.id, address: result.email, expiresAt });
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_emails",
|
||||
{
|
||||
title: "List mailboxes",
|
||||
description: "List temporary mailboxes owned by this API key.",
|
||||
inputSchema: {
|
||||
cursor: z.string().optional().describe("Pagination cursor from a previous call"),
|
||||
},
|
||||
},
|
||||
({ cursor }) =>
|
||||
run(async () => {
|
||||
const data = (await api.listEmails(cursor)) as any;
|
||||
return ok({
|
||||
emails: data.emails.map((e: any) => ({
|
||||
id: e.id,
|
||||
address: e.address,
|
||||
expiresAt: e.expiresAt || null,
|
||||
})),
|
||||
nextCursor: data.nextCursor,
|
||||
total: data.total,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_messages",
|
||||
{
|
||||
title: "List messages",
|
||||
description: "List messages received in a mailbox (newest first).",
|
||||
inputSchema: {
|
||||
emailId: z.string().describe("Mailbox id from create_email"),
|
||||
cursor: z.string().optional().describe("Pagination cursor from a previous call"),
|
||||
},
|
||||
},
|
||||
({ emailId, cursor }) =>
|
||||
run(async () => {
|
||||
const data = (await api.listMessages(emailId, cursor)) as any;
|
||||
return ok({
|
||||
messages: data.messages.map((m: any) => ({
|
||||
id: m.id,
|
||||
from: m.from_address,
|
||||
subject: m.subject,
|
||||
receivedAt: m.received_at ? msToIso(m.received_at) : null,
|
||||
})),
|
||||
nextCursor: data.nextCursor,
|
||||
total: data.total,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"read_message",
|
||||
{
|
||||
title: "Read message",
|
||||
description: "Read the full content (text + html) of a single message.",
|
||||
inputSchema: {
|
||||
emailId: z.string().describe("Mailbox id"),
|
||||
messageId: z.string().describe("Message id from list_messages or wait_for_email"),
|
||||
},
|
||||
},
|
||||
({ emailId, messageId }) =>
|
||||
run(async () => {
|
||||
const data = (await api.getMessage(emailId, messageId)) as any;
|
||||
const msg = data.message;
|
||||
return ok({
|
||||
id: msg.id,
|
||||
from: msg.from_address,
|
||||
to: msg.to_address,
|
||||
subject: msg.subject,
|
||||
content: msg.content,
|
||||
html: msg.html,
|
||||
receivedAt: msg.received_at ? msToIso(msg.received_at) : null,
|
||||
type: msg.type,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"wait_for_email",
|
||||
{
|
||||
title: "Wait for a new email",
|
||||
description:
|
||||
`Poll a mailbox until a new message arrives or the timeout elapses (max ${WAIT_MAX_SEC}s). ` +
|
||||
`On timeout this returns { status: "timeout" } instead of failing — call it again to keep waiting.`,
|
||||
inputSchema: {
|
||||
emailId: z.string().describe("Mailbox id to watch"),
|
||||
timeoutSec: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(WAIT_MAX_SEC)
|
||||
.default(WAIT_DEFAULT_SEC)
|
||||
.describe(`Max seconds to wait (<= ${WAIT_MAX_SEC})`),
|
||||
intervalSec: z.number().int().min(1).max(30).default(5).describe("Seconds between polls"),
|
||||
},
|
||||
},
|
||||
({ emailId, timeoutSec, intervalSec }) =>
|
||||
run(async () => {
|
||||
const result = await pollForNewMessage(emailId, {
|
||||
timeoutMs: Math.min(timeoutSec, WAIT_MAX_SEC) * 1000,
|
||||
intervalMs: intervalSec * 1000,
|
||||
});
|
||||
|
||||
if (result.status === "timeout") {
|
||||
return ok({
|
||||
status: "timeout",
|
||||
elapsedSec: result.elapsedSec,
|
||||
hint: "No new message yet. Call wait_for_email again to continue waiting.",
|
||||
});
|
||||
}
|
||||
|
||||
const msg = result.message!;
|
||||
return ok({
|
||||
status: "received",
|
||||
elapsedSec: result.elapsedSec,
|
||||
message: {
|
||||
messageId: msg.id,
|
||||
from: msg.from_address,
|
||||
subject: msg.subject,
|
||||
receivedAt: msg.received_at ? msToIso(msg.received_at) : null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"send_email",
|
||||
{
|
||||
title: "Send email",
|
||||
description: "Send an email from a temporary address (requires send permission on the server).",
|
||||
inputSchema: {
|
||||
emailId: z.string().describe("Mailbox id to send from"),
|
||||
to: z.string().describe("Recipient email address"),
|
||||
subject: z.string().describe("Email subject"),
|
||||
content: z.string().describe("Email body text"),
|
||||
},
|
||||
},
|
||||
({ emailId, to, subject, content }) =>
|
||||
run(async () => {
|
||||
const result = (await api.sendEmail(emailId, { to, subject, content })) as any;
|
||||
return ok({ success: true, remainingEmails: result.remainingEmails });
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"delete_email",
|
||||
{
|
||||
title: "Delete mailbox",
|
||||
description: "Delete an entire temporary mailbox and all its messages.",
|
||||
inputSchema: {
|
||||
emailId: z.string().describe("Mailbox id to delete"),
|
||||
},
|
||||
},
|
||||
({ emailId }) =>
|
||||
run(async () => {
|
||||
await api.deleteEmail(emailId);
|
||||
return ok({ success: true, deleted: emailId });
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"delete_message",
|
||||
{
|
||||
title: "Delete message",
|
||||
description: "Delete a single message from a mailbox.",
|
||||
inputSchema: {
|
||||
emailId: z.string().describe("Mailbox id"),
|
||||
messageId: z.string().describe("Message id to delete"),
|
||||
},
|
||||
},
|
||||
({ emailId, messageId }) =>
|
||||
run(async () => {
|
||||
await api.deleteMessage(emailId, messageId);
|
||||
return ok({ success: true, deleted: messageId });
|
||||
}),
|
||||
);
|
||||
}
|
||||
14
packages/mcp/tsconfig.json
Normal file
14
packages/mcp/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user