diff --git a/.claude/skills/moemail/SKILL.md b/.claude/skills/moemail/SKILL.md
index 0aacccf..5dc22d6 100644
--- a/.claude/skills/moemail/SKILL.md
+++ b/.claude/skills/moemail/SKILL.md
@@ -83,6 +83,6 @@ $CLI --json read --email-id "$ID" --message-id "$MSG_ID"
## Important details
-- Put `--json` before the subcommand.
+- `--json` is a global flag and works before or after the subcommand.
- Call `create` once and parse both `id` and `address` from the same JSON result.
- Check both `content` and `html` when reading HTML-heavy messages.
diff --git a/README.md b/README.md
index dc474f7..79ae56e 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,7 @@
Webhook Integration •
OpenAPI •
CLI Tool •
+ MCP Server •
Environment Variables •
Github OAuth Config •
Google OAuth Config •
@@ -555,13 +556,25 @@ moemail config set api-key YOUR_API_KEY
# Create temporary email
moemail create --domain moemail.app --expiry 1h --json
+# List mailboxes
+moemail list --json
+
+# List messages in a mailbox
+moemail list --email-id --json
+
# Wait for new messages (polling)
moemail wait --email-id --timeout 120 --json
# Read message content
moemail read --email-id --message-id --json
-# Delete email
+# Send an email from the temporary address
+moemail send --email-id --to user@example.com --subject "Hello" --content "Body text" --json
+
+# Delete a single message
+moemail delete --email-id --message-id
+
+# Delete the whole mailbox
moemail delete --email-id
```
@@ -598,6 +611,47 @@ moemail skill install --platform codex
For full documentation, see [packages/cli/README.md](packages/cli/README.md).
+## MCP Server
+
+MoeMail also ships an [MCP](https://modelcontextprotocol.io) server, so any
+MCP-capable client (Claude Desktop, Cursor, Cline, …) gets native temporary-email
+tools without shelling out to the CLI.
+
+### Tools
+
+| Tool | Description |
+|------|-------------|
+| `create_email` | Create a temporary mailbox (`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; returns `status: "timeout"` to retry) |
+| `send_email` | Send from a temporary address |
+| `delete_email` | Delete a mailbox |
+| `delete_message` | Delete a single message |
+
+### Setup
+
+Add the server to your MCP client config (e.g. Claude Desktop `claude_desktop_config.json`).
+Credentials are passed via environment variables:
+
+```json
+{
+ "mcpServers": {
+ "moemail": {
+ "command": "npx",
+ "args": ["-y", "@moemail/mcp"],
+ "env": {
+ "MOEMAIL_API_KEY": "YOUR_API_KEY",
+ "MOEMAIL_API_URL": "https://moemail.app"
+ }
+ }
+ }
+}
+```
+
+For full documentation, see [packages/mcp/README.md](packages/mcp/README.md).
+
## Environment Variables
### Authentication
diff --git a/README.zh-CN.md b/README.zh-CN.md
index a8edc37..4b7167b 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -30,6 +30,7 @@
Webhook 集成 •
OpenAPI •
CLI 工具 •
+ MCP 服务器 •
环境变量 •
Github OAuth App 配置 •
Google OAuth App 配置 •
@@ -807,13 +808,25 @@ moemail config set api-key YOUR_API_KEY
# 创建临时邮箱
moemail create --domain moemail.app --expiry 1h --json
+# 列出邮箱
+moemail list --json
+
+# 列出邮箱内的邮件
+moemail list --email-id --json
+
# 等待新邮件(轮询)
moemail wait --email-id --timeout 120 --json
# 读取邮件内容
moemail read --email-id --message-id --json
-# 删除邮箱
+# 从临时地址发件
+moemail send --email-id --to user@example.com --subject "你好" --content "正文内容" --json
+
+# 删除单封邮件
+moemail delete --email-id --message-id
+
+# 删除整个邮箱
moemail delete --email-id
```
@@ -837,6 +850,46 @@ CONTENT=$(moemail read --email-id $EMAIL_ID --message-id $MSG_ID --json)
详细文档见 [packages/cli/README.md](packages/cli/README.md)。
+## MCP 服务器
+
+MoeMail 同时提供 [MCP](https://modelcontextprotocol.io) 服务器,让任意支持 MCP 的客户端
+(Claude Desktop、Cursor、Cline 等)无需调用 CLI 即可原生使用临时邮箱工具。
+
+### 工具
+
+| 工具 | 说明 |
+|------|------|
+| `create_email` | 创建临时邮箱(`1h` / `24h` / `3d` / `permanent`) |
+| `list_emails` | 列出该 API Key 下的邮箱 |
+| `list_messages` | 列出邮箱内的邮件 |
+| `read_message` | 读取邮件完整内容(文本 / HTML) |
+| `wait_for_email` | 轮询等待新邮件(有时间上限;超时返回 `status: "timeout"` 可再次调用续等) |
+| `send_email` | 从临时地址发件 |
+| `delete_email` | 删除邮箱 |
+| `delete_message` | 删除单封邮件 |
+
+### 配置
+
+在 MCP 客户端配置中加入该服务器(例如 Claude Desktop 的 `claude_desktop_config.json`),
+凭证通过环境变量传入:
+
+```json
+{
+ "mcpServers": {
+ "moemail": {
+ "command": "npx",
+ "args": ["-y", "@moemail/mcp"],
+ "env": {
+ "MOEMAIL_API_KEY": "你的_API_KEY",
+ "MOEMAIL_API_URL": "https://moemail.app"
+ }
+ }
+ }
+}
+```
+
+详细文档见 [packages/mcp/README.md](packages/mcp/README.md)。
+
## 环境变量
本项目使用以下环境变量:
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 3f8e097..98d7762 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -10,11 +10,15 @@ npm i -g @moemail/cli
## Quick Start
-### 1. Configure default domain
+### 1. Configure endpoint and API key
```bash
-moemail config --domain moemail.app
+moemail config set api-url https://moemail.app
+moemail config set api-key YOUR_API_KEY
```
+> Config is read from `~/.moemail/config.json`, or overridden by the
+> `MOEMAIL_API_URL` / `MOEMAIL_API_KEY` environment variables.
+
### 2. Create a temporary email
```bash
moemail create --expiry 1h
@@ -29,13 +33,14 @@ moemail wait --email-id --timeout 120
| Command | Description | Key Flags |
|---------|-------------|-----------|
-| `config` | Set default domain and options | `--domain `, `--expiry ` |
-| `create` | Create a temporary email address | `--domain `, `--expiry `, `--json` |
-| `list` | List all temporary emails | `--json` |
-| `wait` | Wait for incoming messages | `--email-id `, `--timeout `, `--json` |
-| `read` | Read email message content | `--email-id `, `--message-id `, `--json` |
-| `send` | Send email from temporary address | `--email-id `, `--to `, `--subject `, `--body `, `--json` |
-| `delete` | Delete temporary email | `--email-id ` |
+| `config set` | Set `api-url` or `api-key` | `config set ` |
+| `config list` | Show current configuration | — |
+| `create` | Create a temporary email address | `--name `, `--domain `, `--expiry <1h\|24h\|3d\|permanent>`, `--json` |
+| `list` | List mailboxes, or messages in a mailbox | `--email-id `, `--cursor `, `--json` |
+| `wait` | Wait for incoming messages | `--email-id `, `--timeout `, `--interval `, `--json` |
+| `read` | Read email message content | `--email-id `, `--message-id `, `--format `, `--json` |
+| `send` | Send email from temporary address | `--email-id `, `--to `, `--subject `, `--content `, `--json` |
+| `delete` | Delete a mailbox, or a single message | `--email-id `, `--message-id ` |
| `skill install` | Install AI agent skill | `--platform ` |
## Agent Workflow Example
@@ -90,7 +95,7 @@ All commands support `--json` flag for structured output, making them ideal for
- **Exit Codes**:
- `0`: Command succeeded
- `1`: Runtime error (invalid input, service error)
- - `2`: Configuration error (missing domain, invalid credentials)
+ - `2`: Configuration or authentication error (missing `api-url`/`api-key`, invalid credentials)
## Project Links
diff --git a/packages/cli/package.json b/packages/cli/package.json
index a308a42..a3da650 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@moemail/cli",
- "version": "0.1.2",
+ "version": "1.0.0",
"description": "Agent-first CLI for MoeMail temporary email service",
"type": "module",
"bin": {
@@ -15,5 +15,9 @@
"license": "MIT",
"dependencies": {
"commander": "^12.0.0"
+ },
+ "devDependencies": {
+ "@moemail/core": "file:../core",
+ "@types/node": "^20.0.0"
}
}
diff --git a/packages/cli/skill/SKILL.md b/packages/cli/skill/SKILL.md
index f85a0dc..0089396 100644
--- a/packages/cli/skill/SKILL.md
+++ b/packages/cli/skill/SKILL.md
@@ -62,10 +62,10 @@ moemail --json read --email-id "$ID" --message-id "$MSG_ID"
| `send` | `--email-id`, `--to`, `--subject`, `--content` | — |
| `delete` | `--email-id` | — |
-**Always put `--json` before the subcommand:**
+**`--json` is a global flag — it works before or after the subcommand:**
```bash
-moemail --json create --expiry 24h # ✅ correct
-moemail create --expiry 24h --json # ❌ wrong position
+moemail --json create --expiry 24h # both work
+moemail create --expiry 24h --json
```
## JSON Output Shapes
@@ -83,7 +83,6 @@ moemail create --expiry 24h --json # ❌ wrong position
| Mistake | Fix |
|---------|-----|
| Calling `create` twice to get id + address | Call once, save to variable, parse both fields |
-| `--json` after subcommand | Move `--json` before the subcommand |
| Timeout too short for slow services | Use `--timeout 300` for unreliable senders |
| Inbox expired mid-test | Use `--expiry permanent` for long-running workflows |
| Using `content` field for HTML emails | Check both `content` (plain text) and `html` fields |
diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts
index ed1cb70..ff2bffd 100644
--- a/packages/cli/src/commands/config.ts
+++ b/packages/cli/src/commands/config.ts
@@ -1,5 +1,5 @@
import { Command } from "commander";
-import { loadConfig, saveConfig } from "../config.js";
+import { loadConfig, saveConfig } from "@moemail/core";
export function registerConfigCommand(program: Command) {
const cmd = program.command("config").description("Configure API endpoint and API Key");
diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts
index fca97f3..46c9f1a 100644
--- a/packages/cli/src/commands/create.ts
+++ b/packages/cli/src/commands/create.ts
@@ -1,6 +1,6 @@
import { Command } from "commander";
-import { api } from "../api.js";
-import { log, printJson, printText, msToIso } from "../output.js";
+import { api, msToIso } from "@moemail/core";
+import { fail, log, printJson, printText } from "../output.js";
const EXPIRY_MAP: Record = {
"1h": 3600000,
@@ -54,9 +54,8 @@ export function registerCreateCommand(program: Command) {
printText(`Created: ${result.email} (${expiryLabel})`);
printText(`ID: ${result.id}`);
}
- } catch (e: any) {
- log(`Error: ${e.message}`);
- process.exit(1);
+ } catch (e) {
+ fail(e);
}
});
}
diff --git a/packages/cli/src/commands/delete.ts b/packages/cli/src/commands/delete.ts
index 169dd5c..71f2017 100644
--- a/packages/cli/src/commands/delete.ts
+++ b/packages/cli/src/commands/delete.ts
@@ -1,6 +1,6 @@
import { Command } from "commander";
-import { api } from "../api.js";
-import { log, printJson, printText } from "../output.js";
+import { api } from "@moemail/core";
+import { fail, printJson, printText } from "../output.js";
export function registerDeleteCommand(program: Command) {
program
@@ -26,9 +26,8 @@ export function registerDeleteCommand(program: Command) {
printText(`Deleted mailbox ${opts.emailId}`);
}
}
- } catch (e: any) {
- log(`Error: ${e.message}`);
- process.exit(1);
+ } catch (e) {
+ fail(e);
}
});
}
diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts
index 8c32c26..4ad0ffe 100644
--- a/packages/cli/src/commands/list.ts
+++ b/packages/cli/src/commands/list.ts
@@ -1,6 +1,6 @@
import { Command } from "commander";
-import { api } from "../api.js";
-import { log, printJson, printText, msToIso } from "../output.js";
+import { api, msToIso } from "@moemail/core";
+import { fail, printJson, printText } from "../output.js";
export function registerListCommand(program: Command) {
program
@@ -59,9 +59,8 @@ export function registerListCommand(program: Command) {
}
}
}
- } catch (e: any) {
- log(`Error: ${e.message}`);
- process.exit(1);
+ } catch (e) {
+ fail(e);
}
});
}
diff --git a/packages/cli/src/commands/read.ts b/packages/cli/src/commands/read.ts
index 5e7a085..d5ed009 100644
--- a/packages/cli/src/commands/read.ts
+++ b/packages/cli/src/commands/read.ts
@@ -1,6 +1,6 @@
import { Command } from "commander";
-import { api } from "../api.js";
-import { log, printJson, printText, msToIso } from "../output.js";
+import { api, msToIso } from "@moemail/core";
+import { fail, printJson, printText } from "../output.js";
export function registerReadCommand(program: Command) {
program
@@ -37,9 +37,8 @@ export function registerReadCommand(program: Command) {
printText(msg.content || "(no text content)");
}
}
- } catch (e: any) {
- log(`Error: ${e.message}`);
- process.exit(1);
+ } catch (e) {
+ fail(e);
}
});
}
diff --git a/packages/cli/src/commands/send.ts b/packages/cli/src/commands/send.ts
index d733fd7..f9692b0 100644
--- a/packages/cli/src/commands/send.ts
+++ b/packages/cli/src/commands/send.ts
@@ -1,6 +1,6 @@
import { Command } from "commander";
-import { api } from "../api.js";
-import { log, printJson, printText } from "../output.js";
+import { api } from "@moemail/core";
+import { fail, printJson, printText } from "../output.js";
export function registerSendCommand(program: Command) {
program
@@ -27,9 +27,8 @@ export function registerSendCommand(program: Command) {
} else {
printText(`Email sent successfully. Remaining today: ${result.remainingEmails}`);
}
- } catch (e: any) {
- log(`Error: ${e.message}`);
- process.exit(1);
+ } catch (e) {
+ fail(e);
}
});
}
diff --git a/packages/cli/src/commands/wait.ts b/packages/cli/src/commands/wait.ts
index db5d050..689dadf 100644
--- a/packages/cli/src/commands/wait.ts
+++ b/packages/cli/src/commands/wait.ts
@@ -1,10 +1,6 @@
import { Command } from "commander";
-import { api } from "../api.js";
-import { log, printJson, printText, msToIso } from "../output.js";
-
-function sleep(ms: number): Promise {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
+import { msToIso, pollForNewMessage } from "@moemail/core";
+import { fail, log, printJson, printText } from "../output.js";
export function registerWaitCommand(program: Command) {
program
@@ -17,46 +13,33 @@ export function registerWaitCommand(program: Command) {
const json = program.opts().json;
const timeout = parseInt(opts.timeout, 10);
const interval = parseInt(opts.interval, 10);
- const emailId = opts.emailId;
try {
- const initial = (await api.listMessages(emailId)) as any;
- const knownIds = new Set(initial.messages.map((m: any) => m.id));
+ const result = await pollForNewMessage(opts.emailId, {
+ timeoutMs: timeout * 1000,
+ intervalMs: interval * 1000,
+ onTick: (elapsed) => log(`Polling... (${elapsed}/${timeout}s)`),
+ });
- const startTime = Date.now();
-
- while (true) {
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
- if (elapsed >= timeout) {
- log(`Timeout: no new messages received within ${timeout}s`);
- process.exit(1);
- }
-
- log(`Polling... (${elapsed}/${timeout}s)`);
- await sleep(interval * 1000);
-
- const current = (await api.listMessages(emailId)) as any;
- const newMessages = current.messages.filter((m: any) => !knownIds.has(m.id));
-
- if (newMessages.length > 0) {
- const msg = newMessages[0];
- if (json) {
- printJson({
- messageId: msg.id,
- from: msg.from_address,
- subject: msg.subject,
- receivedAt: msg.received_at ? msToIso(msg.received_at) : null,
- });
- } else {
- printText(`New message from ${msg.from_address}: "${msg.subject}"`);
- printText(`Message ID: ${msg.id}`);
- }
- return;
- }
+ if (result.status === "timeout") {
+ log(`Timeout: no new messages received within ${timeout}s`);
+ process.exit(1);
}
- } catch (e: any) {
- log(`Error: ${e.message}`);
- process.exit(1);
+
+ const msg = result.message!;
+ if (json) {
+ printJson({
+ messageId: msg.id,
+ from: msg.from_address,
+ subject: msg.subject,
+ receivedAt: msg.received_at ? msToIso(msg.received_at) : null,
+ });
+ } else {
+ printText(`New message from ${msg.from_address}: "${msg.subject}"`);
+ printText(`Message ID: ${msg.id}`);
+ }
+ } catch (e) {
+ fail(e);
}
});
}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index eec4660..7f899ed 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -14,7 +14,7 @@ const program = new Command();
program
.name("moemail")
.description("MoeMail CLI — Agent-friendly temporary email tool")
- .version("0.1.2")
+ .version("1.0.0")
.option("--json", "output as JSON");
registerConfigCommand(program);
diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts
index 9cee3da..36454d2 100644
--- a/packages/cli/src/output.ts
+++ b/packages/cli/src/output.ts
@@ -1,3 +1,5 @@
+import { AuthError, ConfigError } from "@moemail/core";
+
/**
* Print JSON to stdout (for --json mode).
*/
@@ -20,8 +22,11 @@ export function log(message: string): void {
}
/**
- * Convert epoch ms timestamp to ISO 8601 string.
+ * Print an error to stderr and exit. Config/auth problems exit 2 (preserving
+ * the previous behaviour); everything else exits 1.
*/
-export function msToIso(ms: number): string {
- return new Date(ms).toISOString();
+export function fail(e: unknown): never {
+ const message = e instanceof Error ? e.message : String(e);
+ log(`Error: ${message}`);
+ process.exit(e instanceof ConfigError || e instanceof AuthError ? 2 : 1);
}
diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json
index 49b7e36..70884f8 100644
--- a/packages/cli/tsconfig.json
+++ b/packages/cli/tsconfig.json
@@ -8,6 +8,7 @@
"outDir": "dist",
"rootDir": "src",
"declaration": true,
+ "types": ["node"],
"skipLibCheck": true
},
"include": ["src"]
diff --git a/packages/core/.gitignore b/packages/core/.gitignore
new file mode 100644
index 0000000..b947077
--- /dev/null
+++ b/packages/core/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+dist/
diff --git a/packages/core/package.json b/packages/core/package.json
new file mode 100644
index 0000000..c0fe53b
--- /dev/null
+++ b/packages/core/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@moemail/core",
+ "version": "0.1.0",
+ "description": "Shared HTTP client and config for MoeMail CLI and MCP server",
+ "type": "module",
+ "main": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "files": ["src"],
+ "license": "MIT",
+ "devDependencies": {
+ "@types/node": "^20.0.0"
+ }
+}
diff --git a/packages/cli/src/api.ts b/packages/core/src/api.ts
similarity index 60%
rename from packages/cli/src/api.ts
rename to packages/core/src/api.ts
index ce85f77..d280f54 100644
--- a/packages/cli/src/api.ts
+++ b/packages/core/src/api.ts
@@ -1,14 +1,5 @@
import { loadConfig } from "./config.js";
-import { log } from "./output.js";
-
-export class ApiError extends Error {
- constructor(
- public status: number,
- message: string,
- ) {
- super(message);
- }
-}
+import { ApiError, AuthError, ConfigError, PermissionError, QuotaError } from "./errors.js";
async function request(
method: string,
@@ -18,12 +9,14 @@ async function request(
const config = loadConfig();
if (!config.apiUrl) {
- log("Error: API URL not configured. Run: moemail config set api-url ");
- process.exit(2);
+ throw new ConfigError(
+ "API URL not configured. Run `moemail config set api-url ` or set MOEMAIL_API_URL.",
+ );
}
if (!config.apiKey) {
- log("Error: API Key not configured. Run: moemail config set api-key ");
- process.exit(2);
+ throw new ConfigError(
+ "API Key not configured. Run `moemail config set api-key ` or set MOEMAIL_API_KEY.",
+ );
}
const url = `${config.apiUrl.replace(/\/$/, "")}${path}`;
@@ -40,22 +33,35 @@ async function request(
body: body ? JSON.stringify(body) : undefined,
});
- if (res.status === 401 || res.status === 403) {
- log("Error: Authentication failed. Check your API Key.");
- process.exit(2);
- }
-
if (res.status === 204) {
return null;
}
- const data = await res.json();
-
- if (!res.ok) {
- throw new ApiError(res.status, (data as any).error || `HTTP ${res.status}`);
+ let data: any = null;
+ try {
+ data = await res.json();
+ } catch {
+ // Non-JSON body (e.g. empty error). Leave data as null.
}
- return data;
+ if (res.ok) {
+ return data;
+ }
+
+ const message = data?.error || `HTTP ${res.status}`;
+
+ // Distinguish the error classes that a MoeMail Pro server returns so callers
+ // can surface them accurately instead of lumping everything into "auth failed".
+ switch (res.status) {
+ case 401:
+ throw new AuthError(message);
+ case 403:
+ throw new PermissionError(message);
+ case 429:
+ throw new QuotaError(message, data?.monthlyLimit, data?.monthlyUsed);
+ default:
+ throw new ApiError(res.status, message);
+ }
}
export const api = {
diff --git a/packages/cli/src/config.ts b/packages/core/src/config.ts
similarity index 94%
rename from packages/cli/src/config.ts
rename to packages/core/src/config.ts
index a7626fd..8a80992 100644
--- a/packages/cli/src/config.ts
+++ b/packages/core/src/config.ts
@@ -22,7 +22,7 @@ export function loadConfig(): CliConfig {
} catch {}
}
- // Env overrides (higher priority)
+ // Env overrides (higher priority) — this is how the MCP server is configured.
if (process.env.MOEMAIL_API_URL) config.apiUrl = process.env.MOEMAIL_API_URL;
if (process.env.MOEMAIL_API_KEY) config.apiKey = process.env.MOEMAIL_API_KEY;
diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts
new file mode 100644
index 0000000..ca606a1
--- /dev/null
+++ b/packages/core/src/errors.ts
@@ -0,0 +1,61 @@
+/**
+ * Error classes shared by CLI and MCP frontends.
+ *
+ * The core HTTP client throws these instead of writing to stderr / exiting,
+ * so each frontend can decide how to surface them (CLI: print + exit,
+ * MCP: structured isError result).
+ */
+
+/** Configuration is missing (apiUrl or apiKey not set). */
+export class ConfigError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "ConfigError";
+ }
+}
+
+/** Generic non-2xx API response. */
+export class ApiError extends Error {
+ constructor(
+ public status: number,
+ message: string,
+ ) {
+ super(message);
+ this.name = "ApiError";
+ }
+}
+
+/** 401 — API key invalid / authentication failed. */
+export class AuthError extends ApiError {
+ constructor(message = "Authentication failed. Check your API Key.") {
+ super(401, message);
+ this.name = "AuthError";
+ }
+}
+
+/**
+ * 403 — request rejected for permission reasons. On a MoeMail Pro server this
+ * covers: no OpenAPI permission, domain requires a higher role, or permanent
+ * mailbox requires Duke. The server message is passed through verbatim.
+ */
+export class PermissionError extends ApiError {
+ constructor(message: string) {
+ super(403, message);
+ this.name = "PermissionError";
+ }
+}
+
+/**
+ * 429 — monthly OpenAPI call quota exceeded (MoeMail Pro). Carries the quota
+ * figures from the response body when present.
+ */
+export class QuotaError extends ApiError {
+ constructor(
+ message: string,
+ public monthlyLimit?: number,
+ public monthlyUsed?: number,
+ ) {
+ super(429, message);
+ this.name = "QuotaError";
+ }
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
new file mode 100644
index 0000000..cba5181
--- /dev/null
+++ b/packages/core/src/index.ts
@@ -0,0 +1,11 @@
+export { api } from "./api.js";
+export { loadConfig, saveConfig, type CliConfig } from "./config.js";
+export {
+ ApiError,
+ AuthError,
+ ConfigError,
+ PermissionError,
+ QuotaError,
+} from "./errors.js";
+export { pollForNewMessage, type NewMessage, type PollResult } from "./poll.js";
+export { msToIso } from "./util.js";
diff --git a/packages/core/src/poll.ts b/packages/core/src/poll.ts
new file mode 100644
index 0000000..3bf6f02
--- /dev/null
+++ b/packages/core/src/poll.ts
@@ -0,0 +1,60 @@
+import { api } from "./api.js";
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+export interface NewMessage {
+ id: string;
+ from_address: string;
+ subject: string;
+ received_at?: number;
+}
+
+export interface PollResult {
+ status: "received" | "timeout";
+ message?: NewMessage;
+ elapsedSec: number;
+}
+
+/**
+ * Poll a mailbox until a message that wasn't present at the start arrives, or
+ * the timeout elapses. Transport-agnostic: the CLI wraps it with stderr
+ * progress, the MCP server returns the result as structured JSON.
+ *
+ * On timeout this resolves with `status: "timeout"` rather than throwing, so an
+ * MCP client can simply call the tool again to keep waiting.
+ */
+export async function pollForNewMessage(
+ emailId: string,
+ opts: {
+ timeoutMs: number;
+ intervalMs: number;
+ onTick?: (elapsedSec: number) => void;
+ },
+): Promise {
+ const initial = (await api.listMessages(emailId)) as any;
+ const knownIds = new Set(initial.messages.map((m: any) => m.id));
+
+ const startTime = Date.now();
+
+ while (true) {
+ const elapsedSec = Math.floor((Date.now() - startTime) / 1000);
+ if (elapsedSec >= opts.timeoutMs / 1000) {
+ return { status: "timeout", elapsedSec };
+ }
+
+ opts.onTick?.(elapsedSec);
+ await sleep(opts.intervalMs);
+
+ const current = (await api.listMessages(emailId)) as any;
+ const fresh = current.messages.filter((m: any) => !knownIds.has(m.id));
+ if (fresh.length > 0) {
+ return {
+ status: "received",
+ message: fresh[0],
+ elapsedSec: Math.floor((Date.now() - startTime) / 1000),
+ };
+ }
+ }
+}
diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts
new file mode 100644
index 0000000..8f2b276
--- /dev/null
+++ b/packages/core/src/util.ts
@@ -0,0 +1,4 @@
+/** Convert epoch ms timestamp to ISO 8601 string. */
+export function msToIso(ms: number): string {
+ return new Date(ms).toISOString();
+}
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
new file mode 100644
index 0000000..6fb5ae5
--- /dev/null
+++ b/packages/core/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "esModuleInterop": true,
+ "strict": true,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": ["node"],
+ "skipLibCheck": true
+ },
+ "include": ["src"]
+}
diff --git a/packages/mcp/.gitignore b/packages/mcp/.gitignore
new file mode 100644
index 0000000..b947077
--- /dev/null
+++ b/packages/mcp/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+dist/
diff --git a/packages/mcp/README.md b/packages/mcp/README.md
new file mode 100644
index 0000000..0bb7345
--- /dev/null
+++ b/packages/mcp/README.md
@@ -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.
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
new file mode 100644
index 0000000..6f34016
--- /dev/null
+++ b/packages/mcp/package.json
@@ -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"
+ }
+}
diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts
new file mode 100644
index 0000000..7a887c1
--- /dev/null
+++ b/packages/mcp/src/index.ts
@@ -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);
+});
diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts
new file mode 100644
index 0000000..af97751
--- /dev/null
+++ b/packages/mcp/src/tools.ts
@@ -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 = {
+ "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): Promise {
+ 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 });
+ }),
+ );
+}
diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json
new file mode 100644
index 0000000..c7677ba
--- /dev/null
+++ b/packages/mcp/tsconfig.json
@@ -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"]
+}