monacloud-mcp 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # monacloud-mcp
2
2
 
3
- `monacloud-mcp` là MCP hợp nhất của MONA Cloud: cài một lần, đăng nhập một MONA Pass và dùng chung ví VND để quản lý tài khoản, chạy app/VPS, tích hợp MONA Pay và đọc catalog MONA Agent ngay trong Claude Code, Codex hoặc Cursor.
3
+ `monacloud-mcp` là MCP hợp nhất của MONA Cloud: cài một lần, đăng nhập một MONA Pass và dùng chung ví VND để quản lý tài khoản, chạy app/VPS, tích hợp MONA Pay, gửi email giao dịch bằng MONA Mail và đọc catalog MONA Agent ngay trong Claude Code, Codex hoặc Cursor.
4
4
 
5
- Human chỉ cần đăng ký, nạp tiền và cung cấp OTP/KYC khi bắt buộc. Các bước tạo tài nguyên, đọc trạng thái, cấu hình webhook, test và deploy được thiết kế để AI agent làm qua MCP.
5
+ Human chỉ cần đăng ký, thêm DNS khi cần, nạp tiền và cung cấp OTP/KYC khi bắt buộc. Các bước tạo tài nguyên, đọc trạng thái, cấu hình webhook, test và deploy được thiết kế để AI agent làm qua MCP.
6
6
 
7
7
  ## Yêu cầu
8
8
 
@@ -101,6 +101,41 @@ Các tên cũ như `monapay_create_qr`, `monapay_link_bank_start`, `monapay_crea
101
101
 
102
102
  `cloud_service_stop` không bị chặn bởi số dư để người dùng luôn có thể hạn chế chi phí. Những lệnh tạo/start/rebuild thật đọc `GET /v1/balance` trước khi gọi compute MONA Cloud.
103
103
 
104
+ ### MONA Mail
105
+
106
+ MONA Mail là dịch vụ gửi email giao dịch cho phần mềm và AI agent của người Việt: một API, trả VND, không cần thẻ, thuộc nhóm MONA Cloud của The MONA Group.
107
+
108
+ 20 tool `mail_*` dùng MONA Pass sẵn có; tài khoản Mail được tạo tự động ở request đầu. Site: https://monamail.vn, API: https://api.monamail.vn.
109
+
110
+ | Tên | Việc |
111
+ |---|---|
112
+ | `mail_account` | Đọc tài khoản, email chủ, quota và bước kế tiếp |
113
+ | `mail_plans` | Đọc giá và quota hiện hành của các gói |
114
+ | `mail_plan_set` | Đổi gói, trừ ví VND khi chọn gói trả phí |
115
+ | `mail_send` | Gửi OTP/thông báo hoặc thử bằng `sandbox: true` |
116
+ | `mail_status` | Đọc trạng thái, events và nội dung sandbox |
117
+ | `mail_list` | Lọc lịch sử theo trạng thái, người nhận và thời gian |
118
+ | `mail_domain_add` | Thêm domain, trả records DNS và hướng dẫn |
119
+ | `mail_domain_verify` | Kiểm DKIM và xác minh domain |
120
+ | `mail_domain_cloudflare` | Thêm DNS bằng token Cloudflare của người dùng, dùng một lần, không lưu/log |
121
+ | `mail_domains_list` | Liệt kê domain cùng trạng thái |
122
+ | `mail_api_key_create` | Tạo key live/test, secret chỉ trả một lần |
123
+ | `mail_api_keys_list` | Xem prefix và trạng thái key |
124
+ | `mail_api_key_revoke` | Thu hồi key của app |
125
+ | `mail_webhook_create` | Đăng ký HTTPS endpoint và các sự kiện email |
126
+ | `mail_webhooks_list` | Liệt kê webhook |
127
+ | `mail_webhook_test` | Gửi payload `email.delivered` mẫu |
128
+ | `mail_suppressions_list` | Xem địa chỉ ngừng gửi và lý do |
129
+ | `mail_suppression_remove` | Gỡ suppression của tài khoản; không gỡ lớp toàn hệ |
130
+ | `mail_template_create` | Tạo mẫu với biến `{{ten_bien}}` |
131
+ | `mail_stats` | Đọc thống kê giao thư và bounce theo thời gian |
132
+
133
+ `mail_send` nhận 1 đến 50 người nhận, tối đa 10 tags, `subject` tối đa 998 ký tự và ít nhất một trong `html`/`text`. Khi dùng `template_id`, template thay cho `subject`/`html`/`text`. Sender `onboarding@monamail.vn` chỉ gửi tới email chủ từ `mail_account`; domain riêng cần verified trước khi gửi.
134
+
135
+ Key chỉ trả một lần; ghi vào `.env` của app dưới tên `MONAMAIL_API_KEY`, không cần in ra chat. MCP tiếp tục dùng MONA Pass; app dùng SDK `monamail` với key `mm_live_` hoặc `mm_test_`. Sandbox bằng `mail_send({ ..., sandbox: true })` gửi header `X-Mona-Sandbox: 1` và trả `sandbox: true`; sandbox không gửi ra Internet, không tính quota hoặc trừ ví. Dùng `mail_status` để xem `sandbox_preview`.
136
+
137
+ Mọi POST gửi `Idempotency-Key` từ `idempotency_key` hoặc tự tạo `mcp-mail-<uuid>`. Truyền key ổn định khi cần retry: trong 24 giờ, cùng key và body trả response cũ; khác body trả `idempotency_conflict`. MCP đưa key vào header, không đưa `sandbox` vào body API. Giá gói lấy bằng `mail_plans`; lỗi thiếu ví từ `mail_plan_set` hướng dẫn gọi `cloud_topup`.
138
+
104
139
  ## Thử 0đ bằng sandbox
105
140
 
106
141
  Truyền `sandbox: true` cho `cloud_vps_create`, `cloud_db_create`, `cloud_service_start`, `cloud_service_rebuild` hoặc `cloud_agent_deploy` để thử luồng mà không cần số dư ví. Có thể bật mặc định cho cả MCP process bằng `MONACLOUD_SANDBOX=1`; các alias `vibecloud_*` có cùng hành vi.
@@ -124,8 +159,9 @@ cloud_services_list({ sandbox: true })
124
159
  ## Resource và prompt
125
160
 
126
161
  - `monacloud://llms`: mô tả máy đọc của toàn stack MONA Cloud.
127
- - `monacloud://status`: health tổng hợp MONA Pass, Billing, compute MONA Cloud và MONA Pay.
162
+ - `monacloud://status`: health tổng hợp MONA Pass, Billing, compute MONA Cloud, MONA Pay và MONA Mail (`/v1/healthz`).
128
163
  - Prompt `dung-app-ban-hang-monacloud`: chuỗi VPS → database → VA/QR → webhook → deploy, chỉ dừng để hỏi nạp tiền hoặc OTP bắt buộc.
164
+ - Prompt `gui-mail-otp-monamail(app_name?, framework?, domain?)`: account → thử onboarding → domain/DNS → verify → API key → `.env` → SDK OTP → webhook bounced; chỉ dừng ở bước thêm DNS hoặc nạp tiền.
129
165
 
130
166
  ## Spend guard và lỗi cho AI
131
167
 
@@ -160,6 +196,17 @@ Agent thực hiện:
160
196
 
161
197
  Chi tiết dành riêng cho agent: [`docs/ai-agent.md`](docs/ai-agent.md).
162
198
 
199
+ ## Ví dụ một lượt: gửi mail OTP
200
+
201
+ Người dùng nói: “Tích hợp gửi mail OTP cho app shop bằng MONA Mail, domain shop.vn.”
202
+
203
+ 1. `mail_account`: lấy email chủ, quota và domain đã xác minh.
204
+ 2. Nếu chưa có domain, `mail_send` từ `onboarding@monamail.vn` tới email chủ, dùng idempotency key riêng; `mail_status` kiểm kết quả.
205
+ 3. `mail_domain_add({ domain: "shop.vn" })`: đưa records để user thêm DNS, hoặc gọi `mail_domain_cloudflare` bằng token của họ; sau đó `mail_domain_verify`.
206
+ 4. `mail_api_key_create({ name: "shop-otp", mode: "live" })`: ghi key trực tiếp vào `.env` dưới tên `MONAMAIL_API_KEY`.
207
+ 5. Viết server dùng `new MonaMail(process.env.MONAMAIL_API_KEY)` và `monamail.emails.send(...)` với `tags: ["otp"]`; kiểm thư bằng `mail_status`.
208
+ 6. Viết endpoint HMAC, `mail_webhook_create` với `events: ["email.bounced"]`, lưu secret rồi `mail_webhook_test`; khi thiếu ví gọi `cloud_topup` và chờ user nạp.
209
+
163
210
  ## Biến môi trường
164
211
 
165
212
  | Biến | Mặc định | Ý nghĩa |
@@ -167,6 +214,7 @@ Chi tiết dành riêng cho agent: [`docs/ai-agent.md`](docs/ai-agent.md).
167
214
  | `MONACLOUD_ISSUER` | `https://pass.monacloud.vn/realms/mona` | OIDC issuer |
168
215
  | `MONACLOUD_BILLING_URL` | `https://billing.monacloud.vn` | Billing/ví API |
169
216
  | `MONAPAY_API` | `https://api.monapay.vn` | MONA Pay API |
217
+ | `MONAMAIL_API` | `https://api.monamail.vn` | MONA Mail API, dùng Bearer MONA Pass của MCP |
170
218
  | `MONACLOUD_API` | `https://api.monacloud.vn` | Compute API của MONA Cloud |
171
219
  | `MONACLOUD_CONSOLE_URL` | `https://monacloud.vn/console` | URL trả cho bước human |
172
220
  | `MONACLOUD_TOKEN` | — | PAT/access token ưu tiên token store |
package/STATUS.md CHANGED
@@ -1,8 +1,36 @@
1
- # STATUS — monacloud-mcp 0.2.1
1
+ # STATUS — monacloud-mcp 0.3.0
2
2
 
3
- Updated: 2026-09-04 (Asia/Ho_Chi_Minh)
3
+ Updated: 2026-09-05 (Asia/Ho_Chi_Minh)
4
4
 
5
- ## Hoàn thành
5
+ ## 0.3.0 — MONA Mail
6
+
7
+ - [x] Thêm đủ 20 tool `mail_*` theo CONTRACT-MONA-MAIL-API §5 trong `src/mail.ts`, đăng ký sau nhóm compute `cloud_*`.
8
+ - [x] `MONAMAIL_API` mặc định `https://api.monamail.vn`; `CloudClients.mail()` dùng `requestJson` và `auth.accessToken()` MONA Pass, không có token cache Mail riêng.
9
+ - [x] Mọi POST có `Idempotency-Key` từ `idempotency_key` hoặc `mcp-mail-<uuid>`; `mail_send` chuyển `sandbox: true` thành `X-Mona-Sandbox: 1` và đánh dấu response.
10
+ - [x] Schema chặn email sai, trên 50 người nhận, trên 10 tags, subject trên 998 ký tự, gói/mode/event ngoài enum, webhook không dùng HTTPS và tham số lạ. Domain chuyển lowercase/IDN sang punycode.
11
+ - [x] Tool mô tả VI/EN có tình huống sử dụng; domain trả records và hướng dẫn DNS/Cloudflare. API key chỉ trả một lần để app lưu `.env` với tên `MONAMAIL_API_KEY`; không thêm log token/key, không lưu token Cloudflare.
12
+ - [x] Lỗi Mail qua `CloudError`: giữ thông tin API, gồm `403 domain_not_verified`; `402 mail_plan_set` trả `insufficient_funds` và bước tiếp theo nhắc `cloud_topup`. `src/http.ts`, `src/errors.ts` và hành vi lỗi tool cũ giữ nguyên.
13
+ - [x] Instructions/entity, `monacloud://llms` và health MONA Mail `/v1/healthz` đã cập nhật. Có prompt `gui-mail-otp-monamail` với account → onboarding → DNS → verify → key → `.env` → SDK OTP → webhook bounced.
14
+ - [x] README có bảng 20 tool, ví dụ OTP 6 bước và env Mail; `docs/ai-agent.md` có ranh giới human DNS/nạp tiền, sandbox, webhook HMAC và idempotency 24 giờ.
15
+ - [x] Version package, MCP server và CLI đồng bộ `0.3.0`. `src/index.ts` chỉ đổi version; không sửa `src/monapay.ts`.
16
+
17
+ Gate ngày 05/09/2026:
18
+
19
+ ```text
20
+ $ node_modules/.bin/tsc -p tsconfig.json
21
+ exit 0, không có diagnostic
22
+
23
+ $ node --test test/*.test.mjs
24
+ tests 23, pass 23, fail 0
25
+ ```
26
+
27
+ Stdio process thật xác nhận **103 tool = 83 cũ + 20 Mail**, 2 resource, 2 prompt, server version `0.3.0`. Test bao phủ auth/header/body, sandbox, records DNS, key một lần, HTTP 402/403/409/429, GET/query/DELETE 204, schema và prompt. Test cũ về compute, MONA Pay, alias và catalog đều xanh.
28
+
29
+ Lựa chọn theo contract: `subject` được bỏ khi có `template_id` vì §3 cho phép template thay nội dung; gửi trực tiếp bắt buộc subject và ít nhất html/text. Các POST ngoài `mail_send` cũng nhận `idempotency_key` tuỳ chọn để retry an toàn. Giá/quota và quyền domain vẫn do API quyết định, không hard-code giá gói.
30
+
31
+ Toàn bộ thay đổi và thư mục tạm của gate nằm trong workspace MCP; thư mục tạm đã dọn sau gate. Không chạy `npm install`, không gọi Internet hoặc production; dùng dependency có sẵn và fetch mock. Chưa kiểm gửi mail thật/DNS/SMTP production vì brief yêu cầu offline.
32
+
33
+ ## Hoàn thành ở 0.2.1
6
34
 
7
35
  - [x] Package Node 20 + TypeScript, ESM, stdio MCP; bin `monacloud-mcp`, version `0.2.1`.
8
36
  - [x] OAuth device flow client `monacloud-mcp`, scope `offline_access`, token store private `0600`, refresh tự động; CLI `login`, `logout`, `whoami`.
@@ -55,3 +83,11 @@ CODEX DONE
55
83
  - Lưu ý test tự động: trang consent Keycloak có form action TƯƠNG ĐỐI (`/realms/mona/login-actions/consent?...`) → script phải ghép host; urllib Python rớt cookie ở hop 302 → dùng curl cookie jar.
56
84
  - Chưa: publish npm (`npm publish` — Claude làm khi deploy), `monapay_link`/`vibecloud_link` chờ endpoint link phía sản phẩm (gói E monapay, brief VIBECLOUD-MONAID đã có `/api/auth/monaid/link`).
57
85
  - 04/09: publish npm `monacloud-mcp` 0.1.0 rồi 0.1.1 (sửa dep monapay-mcp ^0.3.0 → ^0.5.5 vì 0.5.x không khớp ^0.3).
86
+ - 04/09: sandbox bỏ spend guard (Codex) → publish 0.2.1; verify: cloud_vps_create sandbox:true với ví 0đ → job queued; không sandbox → insufficient_funds đúng.
87
+
88
+ CODEX B DONE
89
+
90
+ ## QC Claude 05/09 (JOB B mail_*) — PASS local
91
+ - tsc sạch · `node --test` 23/23 · 20 tool `mail_*` + prompt `gui-mail-otp-monamail` + health `monamail` trong `monacloud://status`.
92
+ - Claude vá: `idempotencyHeaders` luôn gửi `Idempotency-Key` (mặc định `mcp-mail-<uuid>`) thay vì chỉ khi có tham số — AI retry an toàn theo contract §5.
93
+ - Chưa publish npm 0.3.0: chờ `api.monamail.vn` live (deploy theo `handoff/DEPLOY-MONA-MAIL-RUNBOOK.md`) rồi zero-dashboard test prod bằng `handoff/tests/test_mail_zero_dashboard.py`.
package/dist/clients.d.ts CHANGED
@@ -7,6 +7,12 @@ export declare class CloudClients {
7
7
  readonly fetchImpl: typeof fetch;
8
8
  constructor(config: Config, auth: AuthManager, fetchImpl?: typeof fetch);
9
9
  private billing;
10
+ mail<T = unknown>(path: string, options?: {
11
+ method?: string;
12
+ body?: unknown;
13
+ query?: Record<string, string | number | undefined>;
14
+ headers?: Record<string, string>;
15
+ }): Promise<T>;
10
16
  balance(): Promise<unknown>;
11
17
  ledger(cursor?: string, limit?: number): Promise<unknown>;
12
18
  usage(period: string, product?: string): Promise<unknown>;
@@ -19,7 +25,7 @@ export declare class CloudClients {
19
25
  }): Promise<unknown>;
20
26
  budgetGet(): Promise<unknown>;
21
27
  tokenLimit(spendLimitVnd: number, period: string, tokenId?: string): Promise<unknown>;
22
- spendGuard(): Promise<JsonObject>;
28
+ spendGuard(requiredVnd?: number): Promise<JsonObject>;
23
29
  sandboxEnabled(requested?: boolean): boolean;
24
30
  private vibecloudToken;
25
31
  vibecloud<T = unknown>(path: string, options?: {
@@ -31,10 +37,17 @@ export declare class CloudClients {
31
37
  guardedVibecloud(path: string, options?: {
32
38
  method?: string;
33
39
  body?: unknown;
34
- }, requestedSandbox?: boolean): Promise<unknown>;
40
+ }, requestedSandbox?: boolean, requiredVnd?: number): Promise<unknown>;
35
41
  vibecloudLink(): Promise<JsonObject>;
36
42
  monapayLink(): Promise<JsonObject>;
37
43
  prices(): Promise<unknown>;
38
44
  packages(): Promise<unknown>;
45
+ plans(): Promise<unknown>;
46
+ invoicePdf(invoiceId: string): Promise<{
47
+ invoice_id: string;
48
+ path: string;
49
+ mime_type: string;
50
+ size_bytes: number;
51
+ }>;
39
52
  }
40
53
  export {};
package/dist/clients.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { mkdtemp, writeFile, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
2
5
  import { readLinks, writeLinks } from './auth.js';
3
6
  import { CloudError } from './errors.js';
4
- import { requestJson, unwrapData } from './http.js';
7
+ import { apiError, requestJson, unwrapData } from './http.js';
5
8
  const asObject = (value) => (value && typeof value === 'object' ? value : {});
6
9
  function pickString(objects, keys) {
7
10
  for (const object of objects) {
@@ -29,6 +32,40 @@ export class CloudClients {
29
32
  fetchImpl: this.fetchImpl,
30
33
  });
31
34
  }
35
+ async mail(path, options = {}) {
36
+ try {
37
+ return await requestJson(`${this.config.monamailApi}${path}`, {
38
+ ...options,
39
+ headers: {
40
+ ...(options.method === 'POST' ? { 'Idempotency-Key': `mcp-mail-${randomUUID()}` } : {}),
41
+ ...options.headers,
42
+ },
43
+ token: await this.auth.accessToken(),
44
+ fetchImpl: this.fetchImpl,
45
+ });
46
+ }
47
+ catch (error) {
48
+ if (!(error instanceof CloudError) || !error.status)
49
+ throw error;
50
+ // Mail keeps the API's error contract without changing legacy HTTP errors.
51
+ const root = asObject(error.details);
52
+ const objects = [root, asObject(root.detail)];
53
+ const code = path === '/v1/account/plan' && error.status === 402
54
+ ? 'insufficient_funds'
55
+ : pickString(objects, ['code']) || error.code;
56
+ const fallback = code === 'insufficient_funds'
57
+ ? `Gọi cloud_topup để nạp ví, chờ cloud_balance cập nhật rồi thử lại. Console: ${this.config.consoleUrl}.`
58
+ : code === 'quota_exceeded'
59
+ ? 'Gọi mail_plans rồi mail_plan_set để đổi gói trước khi gửi tiếp.'
60
+ : code === 'budget_exceeded'
61
+ ? 'Gọi cloud_budget_get và điều chỉnh ngân sách trước khi thử lại.'
62
+ : error.nextStep;
63
+ const nextStep = pickString(objects, ['next_step']) || fallback;
64
+ throw new CloudError(code, pickString(objects, ['message']) || error.message, code === 'insufficient_funds' && !nextStep.includes('cloud_topup')
65
+ ? `${nextStep} Gọi cloud_topup để nạp ví.`
66
+ : nextStep, { status: error.status, details: error.details, requestId: error.requestId });
67
+ }
68
+ }
32
69
  balance() {
33
70
  return this.billing('/v1/balance');
34
71
  }
@@ -61,11 +98,14 @@ export class CloudClients {
61
98
  fetchImpl: this.fetchImpl,
62
99
  });
63
100
  }
64
- async spendGuard() {
65
- const current = asObject(await this.balance());
101
+ async spendGuard(requiredVnd) {
102
+ const current = asObject(unwrapData(await this.balance()));
66
103
  const balance = current.balance_vnd;
67
- if (typeof balance === 'number' && balance <= 0) {
68
- throw new CloudError('insufficient_funds', ' thiếu tiền: số dư hiện tại 0 đ.', `Nạp tại ${this.config.consoleUrl} rồi gọi lại tool.`);
104
+ if (requiredVnd !== undefined && (typeof balance !== 'number' || !Number.isFinite(balance))) {
105
+ throw new CloudError('invalid_balance', 'Không đọc được số dư để duyệt giá gói.', 'Gọi cloud_balance rồi thử lại.');
106
+ }
107
+ if (typeof balance === 'number' && (requiredVnd === undefined ? balance <= 0 : balance < requiredVnd)) {
108
+ throw new CloudError('insufficient_funds', `Ví thiếu tiền: có ${balance} đ${requiredVnd === undefined ? '' : `, cần ${requiredVnd} đ`}.`, `Gọi cloud_topup để nạp ví tại ${this.config.consoleUrl} rồi gọi lại tool.`);
69
109
  }
70
110
  return current;
71
111
  }
@@ -89,10 +129,10 @@ export class CloudClients {
89
129
  fetchImpl: this.fetchImpl,
90
130
  });
91
131
  }
92
- async guardedVibecloud(path, options = {}, requestedSandbox = false) {
132
+ async guardedVibecloud(path, options = {}, requestedSandbox = false, requiredVnd) {
93
133
  const sandbox = this.sandboxEnabled(requestedSandbox);
94
134
  if (!sandbox)
95
- await this.spendGuard();
135
+ await this.spendGuard(requiredVnd);
96
136
  const result = await this.vibecloud(path, {
97
137
  ...options,
98
138
  ...(sandbox ? { headers: { 'X-Vibecloud-Sandbox': '1' } } : {}),
@@ -167,4 +207,46 @@ export class CloudClients {
167
207
  packages() {
168
208
  return requestJson(`${this.config.vibecloudApi}/api/packages`, { fetchImpl: this.fetchImpl });
169
209
  }
210
+ plans() {
211
+ return requestJson(`${this.config.vibecloudApi}/api/plans`, { fetchImpl: this.fetchImpl });
212
+ }
213
+ async invoicePdf(invoiceId) {
214
+ const token = await this.vibecloudToken();
215
+ let response;
216
+ try {
217
+ response = await this.fetchImpl(`${this.config.vibecloudApi}/api/invoices/${encodeURIComponent(invoiceId)}.pdf`, {
218
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/pdf' },
219
+ signal: AbortSignal.timeout(30_000),
220
+ redirect: 'error',
221
+ });
222
+ }
223
+ catch {
224
+ throw new CloudError('network_error', 'Không tải được PDF hoá đơn.', 'Gọi lại cloud_invoice_pdf với cùng invoice_id.');
225
+ }
226
+ if (!response.ok) {
227
+ const body = await response.text();
228
+ let parsed;
229
+ try {
230
+ parsed = JSON.parse(body);
231
+ }
232
+ catch {
233
+ parsed = { message: body.slice(0, 1000) };
234
+ }
235
+ throw apiError(response, parsed);
236
+ }
237
+ const bytes = Buffer.from(await response.arrayBuffer());
238
+ if (bytes.subarray(0, 5).toString('ascii') !== '%PDF-') {
239
+ throw new CloudError('invalid_pdf', 'API không trả nội dung PDF hợp lệ.', 'Kiểm tra invoice_id rồi thử lại.');
240
+ }
241
+ const directory = await mkdtemp(join(tmpdir(), 'monacloud-invoice-'));
242
+ const path = join(directory, 'invoice.pdf');
243
+ try {
244
+ await writeFile(path, bytes, { mode: 0o600, flag: 'wx' });
245
+ }
246
+ catch (error) {
247
+ await rm(directory, { recursive: true, force: true });
248
+ throw error;
249
+ }
250
+ return { invoice_id: invoiceId, path, mime_type: 'application/pdf', size_bytes: bytes.length };
251
+ }
170
252
  }
@@ -0,0 +1,18 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { CloudClients } from './clients.js';
3
+ type Row = Record<string, unknown>;
4
+ export declare const APP_FLOW = "Khi user n\u00F3i \"deploy repo\", d\u00F9ng cloud_app_create (app t\u1EEB git \u0111ang m\u1EDF). \u0110\u1ECDc cloud_app_host_list, cloud_prices/cloud_packages v\u00E0 cloud_balance; n\u1EBFu ch\u01B0a c\u00F3 app host th\u00EC cloud_app_create sandbox=true tr\u01B0\u1EDBc \u0111\u1EC3 l\u1EA5y \u01B0\u1EDBc t\u00EDnh. \u0110\u1ECDc \u2192 \u01B0\u1EDBc t\u00EDnh VND \u2192 h\u1ECFi duy\u1EC7t n\u1EBFu ch\u01B0a \u0111\u01B0\u1EE3c duy\u1EC7t \u2192 l\u00E0m th\u1EADt \u2192 poll t\u1EDBi done/succeeded \u2192 ki\u1EC3m URL. Kh\u00F4ng g\u1ECDi agent_deploy cho repo git. / Deploy a repository with cloud_app_create; inspect, estimate, obtain approval, then deploy.";
5
+ export declare function rows(value: unknown, key: string): Row[];
6
+ export declare function planEstimate(clients: CloudClients, code: string, cycle?: string): Promise<{
7
+ plan: Row;
8
+ estimate: {
9
+ billing_mode: string;
10
+ plan_code: string;
11
+ period: string;
12
+ amount_vnd: number;
13
+ currency: string;
14
+ };
15
+ }>;
16
+ export declare function createVps(clients: CloudClients, body: Row): Promise<unknown>;
17
+ export declare function registerComputeTools(server: McpServer, clients: CloudClients): void;
18
+ export {};
@@ -0,0 +1,103 @@
1
+ import { z } from 'zod';
2
+ import { CloudError, runTool } from './errors.js';
3
+ import { unwrapData } from './http.js';
4
+ import { finishAppJob } from './jobs.js';
5
+ export const APP_FLOW = 'Khi user nói "deploy repo", dùng cloud_app_create (app từ git đang mở). Đọc cloud_app_host_list, cloud_prices/cloud_packages và cloud_balance; nếu chưa có app host thì cloud_app_create sandbox=true trước để lấy ước tính. Đọc → ước tính VND → hỏi duyệt nếu chưa được duyệt → làm thật → poll tới done/succeeded → kiểm URL. Không gọi agent_deploy cho repo git. / Deploy a repository with cloud_app_create; inspect, estimate, obtain approval, then deploy.';
6
+ const sandbox = z.boolean().optional().describe('Thử 0đ, không tạo hạ tầng thật / Sandbox, no charge');
7
+ const id = z.string().trim().min(1).max(255);
8
+ const planCode = z.string().trim().min(2).max(64);
9
+ const period = z.enum(['month', 'year']);
10
+ const object = (value) => value && typeof value === 'object' ? value : {};
11
+ export function rows(value, key) {
12
+ const data = unwrapData(value);
13
+ const list = Array.isArray(data) ? data : object(data)[key];
14
+ return Array.isArray(list) ? list.filter((row) => row && typeof row === 'object') : [];
15
+ }
16
+ export async function planEstimate(clients, code, cycle = 'month') {
17
+ const plan = rows(await clients.plans(), 'plans').find((row) => row.code === code && row.active !== false);
18
+ if (!plan)
19
+ throw new CloudError('plan_not_found', `Không có gói đang mở: ${code}.`, 'Gọi cloud_plan_list để chọn plan_code.');
20
+ const amount = plan[cycle === 'year' ? 'price_year_vnd' : 'price_month_vnd'];
21
+ if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
22
+ throw new CloudError('invalid_plan_price', 'Không đọc được giá gói.', 'Gọi cloud_plan_list rồi thử lại; không tự đoán giá.');
23
+ }
24
+ return { plan, estimate: { billing_mode: 'monthly', plan_code: code, period: cycle, amount_vnd: amount, currency: 'VND' } };
25
+ }
26
+ export async function createVps(clients, body) {
27
+ const { sandbox: requested, ...payload } = body;
28
+ if (payload.billing_mode !== 'monthly')
29
+ return clients.guardedVibecloud('/api/lxc', { method: 'POST', body: payload }, requested === true);
30
+ const { plan, estimate } = await planEstimate(clients, String(payload.plan_code), String(payload.period || 'month'));
31
+ // Wave A rejects mixed sizing. Strip all hourly sizing before the real request.
32
+ delete payload.cpu;
33
+ delete payload.ram_gb;
34
+ delete payload.disk_gb;
35
+ delete payload.package_slug;
36
+ const isSandbox = clients.sandboxEnabled(requested === true);
37
+ const outgoing = isSandbox
38
+ ? { app_name: payload.app_name, cpu: plan.cpu, ram_gb: plan.ram_gb, disk_gb: plan.disk_gb }
39
+ : payload;
40
+ const result = await clients.guardedVibecloud('/api/lxc', { method: 'POST', body: outgoing }, isSandbox, estimate.amount_vnd);
41
+ return { ...object(result), estimate, ...(isSandbox ? {
42
+ preview_billing_mode: 'hourly', requested_billing_mode: 'monthly',
43
+ next_step: 'Sandbox chỉ thử cấu hình gói qua hourly (0đ); chưa tạo subscription. Duyệt giá estimate rồi gọi cloud_vps_create billing_mode=monthly, sandbox=false.',
44
+ } : {}) };
45
+ }
46
+ export function registerComputeTools(server, clients) {
47
+ // Every new compute tool keeps a mechanical legacy alias, using the same schema and handler.
48
+ const register = (name, description, schema, handler) => {
49
+ for (const toolName of [name, name.replace(/^cloud_/, 'vibecloud_')]) {
50
+ server.registerTool(toolName, {
51
+ description: toolName === name ? description : `Alias tương thích của ${name}. / Compatibility alias. ${description}`,
52
+ inputSchema: schema,
53
+ }, (args) => runTool(() => handler(args)));
54
+ }
55
+ };
56
+ register('cloud_plan_list', 'Bảng gói cùng giá tháng/năm; gợi ý gói rẻ nhất đủ CPU/RAM/đĩa yêu cầu, admin_only không tự chọn. Đọc trước khi duyệt chi phí. / List plans, prices and sizing recommendation.', z.object({ cpu: z.number().int().positive().optional(), ram_gb: z.number().positive().optional(), disk_gb: z.number().positive().optional() }).strict(), async (needs) => {
57
+ const plans = rows(await clients.plans(), 'plans');
58
+ const eligible = plans.filter((plan) => plan.active !== false && !plan.admin_only
59
+ && ['cpu', 'ram_gb', 'disk_gb'].every((key) => typeof plan[key] === 'number' && Number(plan[key]) >= (needs[key] || 0))
60
+ && typeof plan.price_month_vnd === 'number').sort((a, b) => Number(a.price_month_vnd) - Number(b.price_month_vnd));
61
+ return { plans, recommendation: eligible[0] ? { plan_code: eligible[0].code, reason: 'Gói giá tháng thấp nhất đáp ứng cấu hình yêu cầu; kiểm tra tải thực tế, backup và included_db.' } : null,
62
+ next_step: 'Chọn plan_code và month/year, báo giá tương ứng rồi hỏi duyệt trước cloud_vps_create.' };
63
+ });
64
+ register('cloud_subscription_list', 'Đọc các gói đang dùng, kỳ gia hạn và auto-renew. / List subscriptions.', z.object({}).strict(), () => clients.vibecloud('/api/subscriptions'));
65
+ register('cloud_subscription_update', 'Đổi gói/chu kỳ/gia hạn sau khi đọc subscription và giá, ước tính rồi được duyệt. Upgrade tính prorate, downgrade kỳ sau. Huỷ: auto_renew=false, cancel_action=hourly|stop. / Update or cancel renewal.', z.object({ service_id: id, plan_code: planCode.optional(), period: period.optional(), auto_renew: z.boolean().optional(), cancel_action: z.enum(['hourly', 'stop']).optional() }).strict()
66
+ .refine((args) => [args.plan_code, args.period, args.auto_renew, args.cancel_action].some((value) => value !== undefined), 'Cần ít nhất một thay đổi subscription.'), async ({ service_id, ...body }) => {
67
+ // Cancellation must remain possible with an empty wallet. Proration is authoritative at the API.
68
+ return clients.vibecloud(`/api/services/${encodeURIComponent(service_id)}/subscription`, { method: 'POST', body });
69
+ });
70
+ register('cloud_invoice_list', 'Đọc hoá đơn hàng tháng của tài khoản. / List monthly invoices.', z.object({}).strict(), () => clients.vibecloud('/api/invoices'));
71
+ register('cloud_invoice_pdf', 'Tải PDF hoá đơn vào file tạm riêng tư, trả path; sao chép ra nơi cần giữ trước khi hệ điều hành dọn. / Download invoice PDF to a private temporary file.', z.object({ invoice_id: id }).strict(), ({ invoice_id }) => clients.invoicePdf(invoice_id));
72
+ register('cloud_credit_redeem', 'Dùng mã credit người dùng cung cấp sau khi họ đồng ý; không thử đoán mã. / Redeem an approved promotional credit code.', z.object({ code: z.string().trim().min(2).max(64) }).strict(), ({ code }) => clients.vibecloud('/api/credit-codes/redeem', { method: 'POST', body: { code: code.toUpperCase() } }));
73
+ const appNotice = 'App từ git đang mở; endpoint có thể chưa live. / Git apps rollout in progress. ';
74
+ const poll = { wait: z.boolean().default(true), interval_sec: z.number().int().min(1).max(30).default(3), timeout_sec: z.number().int().min(1).max(600).default(600) };
75
+ const env = z.record(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/), z.string());
76
+ const domain = z.string().trim().min(3).max(253).regex(/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/, 'Chỉ hostname, không URL/path.');
77
+ const appId = z.object({ app_id: id, sandbox }).strict();
78
+ const repo = z.string().trim().min(1).max(2048).refine((value) => {
79
+ if (/^git@[a-zA-Z0-9.-]+:[^\s]+$/.test(value))
80
+ return true;
81
+ try {
82
+ const u = new URL(value);
83
+ return ['https:', 'ssh:'].includes(u.protocol) && !u.password && (u.protocol === 'ssh:' || !u.username) && !u.search && !u.hash && u.pathname.length > 1;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }, 'Dùng HTTPS hoặc SSH git URL không chứa token/mật khẩu.');
89
+ const compute = async (path, options, requested) => {
90
+ const isSandbox = clients.sandboxEnabled(requested);
91
+ const result = await clients.vibecloud(path, { ...options, ...(isSandbox ? { headers: { 'X-Vibecloud-Sandbox': '1' } } : {}) });
92
+ return isSandbox ? { ...(Array.isArray(result) ? { items: result } : object(result)), sandbox: true } : result;
93
+ };
94
+ register('cloud_app_create', appNotice + APP_FLOW, z.object({ repo_url: repo, branch: z.string().trim().min(1).max(255).default('main'), build_type: z.enum(['dockerfile', 'nixpacks', 'static']).default('dockerfile'), dockerfile: z.string().min(1).max(1024).default('Dockerfile'), env: env.default({}), domain: domain.optional(), app_host_id: id.optional(), port: z.number().int().min(1).max(65535).default(3000), sandbox, ...poll }).strict(), async ({ sandbox: requested, wait, interval_sec, timeout_sec, ...body }) => finishAppJob(clients, await clients.guardedVibecloud('/api/apps', { method: 'POST', body }, requested), clients.sandboxEnabled(requested), wait, interval_sec, timeout_sec));
95
+ register('cloud_app_list', appNotice + 'Liệt kê app trước khi tạo để tránh trùng. / List apps.', z.object({ sandbox }).strict(), ({ sandbox }) => compute('/api/apps', {}, sandbox));
96
+ register('cloud_app_host_list', appNotice + 'Đọc app host; chưa có host thì sandbox cloud_app_create trước để ước tính. / List app hosts.', z.object({ sandbox }).strict(), ({ sandbox }) => compute('/api/app-hosts', {}, sandbox));
97
+ register('cloud_app_get', appNotice + 'Đọc status, URL, lần deploy và app host. / Inspect app.', appId, ({ app_id, sandbox }) => compute(`/api/apps/${encodeURIComponent(app_id)}`, {}, sandbox));
98
+ register('cloud_app_deploy', appNotice + 'Deploy lại sau khi đọc app/log và được duyệt; poll job tới kết quả. / Redeploy and poll.', appId.extend(poll), async ({ app_id, sandbox, wait, interval_sec, timeout_sec }) => finishAppJob(clients, await clients.guardedVibecloud(`/api/apps/${encodeURIComponent(app_id)}/deploy`, { method: 'POST' }, sandbox), clients.sandboxEnabled(sandbox), wait, interval_sec, timeout_sec));
99
+ register('cloud_app_env_set', appNotice + 'Đặt env sau khi được duyệt, gửi đầy đủ map cần giữ; không log secret. / Set app environment.', appId.extend({ env }), ({ app_id, env, sandbox }) => compute(`/api/apps/${encodeURIComponent(app_id)}/env`, { method: 'PUT', body: { env } }, sandbox));
100
+ register('cloud_app_domain_add', appNotice + 'Thêm domain đã được duyệt, trả hướng dẫn CNAME từ API; chờ DNS trước kiểm HTTPS. / Attach custom domain.', appId.extend({ host: domain }), ({ app_id, host, sandbox }) => compute(`/api/apps/${encodeURIComponent(app_id)}/domains`, { method: 'POST', body: { host } }, sandbox));
101
+ register('cloud_app_logs', appNotice + 'Đọc tối đa 500 dòng log; có thể chứa secret, không đưa nguyên log ra công khai. / Read deployment logs.', appId.extend({ deployment: id.optional() }), ({ app_id, deployment, sandbox }) => compute(`/api/apps/${encodeURIComponent(app_id)}/logs`, { query: { deployment } }, sandbox));
102
+ register('cloud_app_delete', appNotice + 'Sau khi user duyệt xoá: xoá app/domain/A record; app host vẫn có thể tính phí. / Delete an approved app.', appId, ({ app_id, sandbox }) => compute(`/api/apps/${encodeURIComponent(app_id)}`, { method: 'DELETE' }, sandbox));
103
+ }
package/dist/config.d.ts CHANGED
@@ -2,6 +2,7 @@ export type Config = {
2
2
  issuer: string;
3
3
  billingUrl: string;
4
4
  monapayApi: string;
5
+ monamailApi: string;
5
6
  vibecloudApi: string;
6
7
  consoleUrl: string;
7
8
  clientId: string;
package/dist/config.js CHANGED
@@ -8,6 +8,7 @@ export function readConfig(env = process.env) {
8
8
  issuer: cleanBaseUrl(env.MONACLOUD_ISSUER || 'https://pass.monacloud.vn/realms/mona'),
9
9
  billingUrl: cleanBaseUrl(env.MONACLOUD_BILLING_URL || 'https://billing.monacloud.vn'),
10
10
  monapayApi: cleanBaseUrl(env.MONAPAY_API || env.MONAPAY_BASE_URL || 'https://api.monapay.vn'),
11
+ monamailApi: cleanBaseUrl(env.MONAMAIL_API || 'https://api.monamail.vn'),
11
12
  vibecloudApi: cleanBaseUrl(env.MONACLOUD_API || env.VIBECLOUD_API || env.VIBECLOUD_API_URL || 'https://api.monacloud.vn'),
12
13
  consoleUrl: cleanBaseUrl(env.MONACLOUD_CONSOLE_URL || 'https://monacloud.vn/console'),
13
14
  clientId: env.MONACLOUD_CLIENT_ID || 'monacloud-mcp',
package/dist/http.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { CloudError } from './errors.js';
1
2
  export type HttpOptions = {
2
3
  method?: string;
3
4
  token?: string;
@@ -7,5 +8,6 @@ export type HttpOptions = {
7
8
  fetchImpl?: typeof fetch;
8
9
  timeoutMs?: number;
9
10
  };
11
+ export declare function apiError(response: Response, body: unknown): CloudError;
10
12
  export declare function requestJson<T = unknown>(url: string, options?: HttpOptions): Promise<T>;
11
13
  export declare function unwrapData<T = unknown>(value: unknown): T;
package/dist/http.js CHANGED
@@ -8,7 +8,7 @@ const nestedString = (body, key) => {
8
8
  const nested = detail[key];
9
9
  return typeof nested === 'string' && nested ? nested : undefined;
10
10
  };
11
- function apiError(response, body) {
11
+ export function apiError(response, body) {
12
12
  const parsed = asRecord(body);
13
13
  const requestId = response.headers.get('x-request-id')
14
14
  || nestedString(parsed, 'request_id');
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@ async function main() {
22
22
  return;
23
23
  }
24
24
  if (command === '--version' || command === '-v') {
25
- console.log('0.2.1');
25
+ console.log('0.3.0');
26
26
  return;
27
27
  }
28
28
  if (command === '--help' || command === '-h' || command === 'help') {
package/dist/jobs.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { CloudClients } from './clients.js';
2
+ export declare const TERMINAL_JOBS: Set<string>;
3
+ export declare function pollJob(clients: CloudClients, jobId: string, wait: boolean, intervalSeconds: number, timeoutSeconds: number, sandbox: boolean): Promise<Record<string, unknown>>;
4
+ export declare function finishAppJob(clients: CloudClients, created: unknown, sandbox: boolean, wait: boolean, interval: number, timeout: number): Promise<{
5
+ job_id?: string | undefined;
6
+ url?: string | undefined;
7
+ application_id?: string | undefined;
8
+ sandbox?: boolean | undefined;
9
+ }>;
package/dist/jobs.js ADDED
@@ -0,0 +1,38 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { CloudError } from './errors.js';
3
+ import { unwrapData } from './http.js';
4
+ export const TERMINAL_JOBS = new Set(['done', 'succeeded', 'failed', 'cancelled', 'error']);
5
+ const object = (value) => (value && typeof value === 'object' ? value : {});
6
+ export async function pollJob(clients, jobId, wait, intervalSeconds, timeoutSeconds, sandbox) {
7
+ const deadline = Date.now() + timeoutSeconds * 1000;
8
+ const read = async () => object(unwrapData(await clients.vibecloud(`/api/jobs/${encodeURIComponent(jobId)}`)));
9
+ let result = await read();
10
+ while (wait && !TERMINAL_JOBS.has(String(result.status)) && Date.now() < deadline) {
11
+ await delay(Math.min(intervalSeconds * 1000, Math.max(0, deadline - Date.now())));
12
+ if (Date.now() >= deadline)
13
+ break;
14
+ result = await read();
15
+ }
16
+ return {
17
+ ...result,
18
+ ...(sandbox ? { sandbox: true } : {}),
19
+ ...(wait && !TERMINAL_JOBS.has(String(result.status)) ? {
20
+ polling: 'timeout', job_id: jobId,
21
+ next_step: `Gọi lại cloud_job_status với job_id=${jobId}; không tạo lại app.`,
22
+ } : {}),
23
+ };
24
+ }
25
+ export async function finishAppJob(clients, created, sandbox, wait, interval, timeout) {
26
+ const start = object(unwrapData(created));
27
+ const jobId = typeof start.job_id === 'string' ? start.job_id
28
+ : !start.url && typeof start.id === 'string' ? start.id : undefined;
29
+ const job = jobId && wait ? await pollJob(clients, jobId, true, interval, timeout, sandbox) : start;
30
+ const result = object(job.result);
31
+ if (['failed', 'error', 'cancelled'].includes(String(job.status)) || result.status === 'error') {
32
+ throw new CloudError('app_deploy_failed', `Deploy thất bại${jobId ? ` (job ${jobId})` : ''}: ${job.error || result.errorMessage || result.error || 'kiểm tra log build'}`, 'Gọi cloud_app_logs và cloud_app_get để sửa lỗi; chỉ cloud_app_deploy lại sau khi sửa.');
33
+ }
34
+ return { ...start, ...job, ...(jobId ? { job_id: jobId } : {}),
35
+ ...(typeof result.url === 'string' ? { url: result.url } : {}),
36
+ ...(typeof result.application_id === 'string' ? { application_id: result.application_id } : {}),
37
+ ...(sandbox ? { sandbox: true } : {}) };
38
+ }
package/dist/mail.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { CloudClients } from './clients.js';
3
+ import type { Config } from './config.js';
4
+ export declare function registerMailTools(server: McpServer, clients: CloudClients, config: Config): void;
package/dist/mail.js ADDED
@@ -0,0 +1,183 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { domainToASCII } from 'node:url';
3
+ import { z } from 'zod';
4
+ import { runTool } from './errors.js';
5
+ const id = z.string().trim().min(1).max(255);
6
+ const email = z.string().max(320).regex(/^[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+$/, 'Cần địa chỉ email hợp lệ.');
7
+ const mailbox = z.union([
8
+ email,
9
+ z.string().max(998).regex(/^[^<>\r\n]+<[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+>$/, 'Dùng email hoặc Tên <email>.'),
10
+ ]);
11
+ const recipients = z.union([mailbox, z.array(mailbox).min(1).max(50)]);
12
+ const subject = z.string().min(1).max(998).regex(/^[^\r\n]+$/, 'Subject không chứa xuống dòng.');
13
+ const domain = z.string().trim().toLowerCase().transform(domainToASCII).pipe(z.string().max(253).regex(/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/, 'Cần domain hợp lệ, không kèm URL.'));
14
+ const httpsUrl = z.string().url().refine((value) => new URL(value).protocol === 'https:', 'Cần URL HTTPS.');
15
+ const timestamp = z.iso.datetime({ offset: true });
16
+ const dateOrTimestamp = z.union([z.iso.date(), timestamp]);
17
+ const idempotency = {
18
+ idempotency_key: z.string().min(1).max(255).regex(/^[\x21-\x7e]+$/, 'Idempotency key dùng ký tự ASCII, không khoảng trắng.').optional()
19
+ .describe('Giữ cùng key khi thử lại cùng yêu cầu trong 24 giờ. / Reuse for retries.'),
20
+ };
21
+ const empty = z.object({}).strict();
22
+ const events = z.enum([
23
+ 'email.sent', 'email.delivered', 'email.deferred', 'email.bounced',
24
+ 'email.complained', 'email.suppressed', 'email.failed', 'domain.verified',
25
+ ]);
26
+ const status = z.enum(['queued', 'sent', 'delivered', 'deferred', 'bounced', 'complained', 'suppressed', 'failed', 'sandbox']);
27
+ function idempotencyHeaders(key) {
28
+ return { 'Idempotency-Key': key || `mcp-mail-${randomUUID()}` };
29
+ }
30
+ export function registerMailTools(server, clients, config) {
31
+ server.registerTool('mail_account', {
32
+ title: 'Tài khoản MONA Mail',
33
+ description: `Khi bắt đầu tích hợp email, đọc tài khoản, quota và bước kế tiếp. / Use first to read account and quota at ${config.monamailApi}.`,
34
+ inputSchema: empty,
35
+ }, () => runTool(() => clients.mail('/v1/account')));
36
+ server.registerTool('mail_plans', {
37
+ title: 'Gói MONA Mail',
38
+ description: 'Khi chọn gói gửi mail, đọc giá và quota hiện hành. / Use to compare current email plans.',
39
+ inputSchema: empty,
40
+ }, () => runTool(() => clients.mail('/v1/plans')));
41
+ server.registerTool('mail_plan_set', {
42
+ title: 'Đổi gói MONA Mail',
43
+ description: 'Khi cần đổi quota, chọn gói; gói trả phí trừ ví VND, thiếu tiền gọi cloud_topup. / Use to change the email plan.',
44
+ inputSchema: z.object({ plan: z.enum(['free', 'khoi-nghiep', 'kinh-doanh', 'doanh-nghiep']) }).strict(),
45
+ }, ({ plan }) => runTool(() => clients.mail('/v1/account/plan', { method: 'PUT', body: { plan } })));
46
+ server.registerTool('mail_send', {
47
+ title: 'Gửi email giao dịch',
48
+ description: 'Khi gửi OTP hoặc thông báo, dùng domain đã verify; onboarding@monamail.vn chỉ gửi tới email chủ. sandbox=true thử 0đ, không gửi ra Internet. / Use to send transactional email or test in sandbox.',
49
+ inputSchema: z.object({
50
+ from: mailbox,
51
+ to: recipients,
52
+ subject: subject.optional().describe('Bắt buộc nếu không dùng template_id. / Required without a template.'),
53
+ html: z.string().min(1).optional(),
54
+ text: z.string().min(1).optional(),
55
+ reply_to: recipients.optional(),
56
+ tags: z.array(z.string().regex(/^[a-z0-9_-]+$/)).max(10).optional(),
57
+ template_id: id.optional(),
58
+ variables: z.record(z.string(), z.unknown()).optional(),
59
+ unsubscribe_url: httpsUrl.optional(),
60
+ ...idempotency,
61
+ sandbox: z.boolean().optional().describe('Gửi X-Mona-Sandbox: 1, không tính quota hoặc trừ ví. / Test without delivery or charges.'),
62
+ }).strict().superRefine((value, context) => {
63
+ if (!value.template_id) {
64
+ if (!value.subject)
65
+ context.addIssue({ code: 'custom', path: ['subject'], message: 'Cần subject khi không dùng template_id.' });
66
+ if (!value.html && !value.text)
67
+ context.addIssue({ code: 'custom', path: ['html'], message: 'Cần ít nhất html hoặc text khi không dùng template_id.' });
68
+ }
69
+ }),
70
+ }, ({ sandbox, idempotency_key, ...body }) => runTool(async () => {
71
+ const result = await clients.mail('/v1/emails', {
72
+ method: 'POST',
73
+ headers: { ...idempotencyHeaders(idempotency_key), ...(sandbox ? { 'X-Mona-Sandbox': '1' } : {}) },
74
+ body,
75
+ });
76
+ return sandbox ? { ...result, sandbox: true } : result;
77
+ }));
78
+ server.registerTool('mail_status', {
79
+ title: 'Trạng thái email',
80
+ description: 'Khi cần xác nhận thư đã giao, đọc trạng thái, events và sandbox_preview. / Use to inspect an email after sending.',
81
+ inputSchema: z.object({ email_id: id }).strict(),
82
+ }, ({ email_id }) => runTool(() => clients.mail(`/v1/emails/${encodeURIComponent(email_id)}`)));
83
+ server.registerTool('mail_list', {
84
+ title: 'Danh sách email',
85
+ description: 'Khi tra lịch sử gửi, lọc theo trạng thái, người nhận hoặc thời gian. / Use to search sent email history.',
86
+ inputSchema: z.object({
87
+ limit: z.number().int().min(1).max(100).default(20),
88
+ status: status.optional(),
89
+ to: email.optional(),
90
+ since: timestamp.optional(),
91
+ }).strict(),
92
+ }, (query) => runTool(() => clients.mail('/v1/emails', { query })));
93
+ server.registerTool('mail_domain_add', {
94
+ title: 'Thêm domain gửi email',
95
+ description: 'Khi gửi bằng domain của app, thêm domain. Trả record DNS; nếu người dùng dùng Cloudflare có thể gọi mail_domain_cloudflare với token của họ (không lưu). / Use to register a sender domain and get DNS records.',
96
+ inputSchema: z.object({
97
+ domain: domain.refine((value) => value !== 'monamail.vn' && !value.endsWith('.monamail.vn'), 'Dùng domain của anh chị, không dùng domain MONA Mail.'),
98
+ ...idempotency,
99
+ }).strict(),
100
+ }, ({ domain, idempotency_key }) => runTool(async () => ({
101
+ ...await clients.mail('/v1/domains', {
102
+ method: 'POST', headers: idempotencyHeaders(idempotency_key), body: { domain },
103
+ }),
104
+ instructions: 'Thêm các records vào DNS rồi gọi mail_domain_verify. Nếu dùng Cloudflare, gọi mail_domain_cloudflare với token người dùng cung cấp; token chỉ dùng một lần, không lưu. DKIM đúng là đủ để verify; SPF/DMARC là cảnh báo. Nếu đã có SPF, thêm include:_spf.monamail.vn vào record hiện có.',
105
+ })));
106
+ server.registerTool('mail_domain_verify', {
107
+ title: 'Xác minh DNS domain',
108
+ description: 'Khi đã thêm DNS, kiểm DKIM và trạng thái domain. / Use after adding DNS records to verify the sender domain.',
109
+ inputSchema: z.object({ domain_id: id, ...idempotency }).strict(),
110
+ }, ({ domain_id, idempotency_key }) => runTool(() => clients.mail(`/v1/domains/${encodeURIComponent(domain_id)}/verify`, {
111
+ method: 'POST', headers: idempotencyHeaders(idempotency_key),
112
+ })));
113
+ server.registerTool('mail_domain_cloudflare', {
114
+ title: 'Thêm DNS qua Cloudflare',
115
+ description: 'Khi người dùng cung cấp token Cloudflare, thêm DNS rồi verify domain. Token dùng một lần, không lưu, không log. / Use a user-provided Cloudflare token to configure DNS and verify.',
116
+ inputSchema: z.object({ domain_id: id, api_token: z.string().trim().min(1).max(4096), ...idempotency }).strict(),
117
+ }, ({ domain_id, api_token, idempotency_key }) => runTool(() => clients.mail(`/v1/domains/${encodeURIComponent(domain_id)}/cloudflare`, {
118
+ method: 'POST', headers: idempotencyHeaders(idempotency_key), body: { api_token },
119
+ })));
120
+ server.registerTool('mail_domains_list', {
121
+ title: 'Domain MONA Mail',
122
+ description: 'Khi chọn địa chỉ gửi, xem domain và trạng thái xác minh. / Use to find verified sender domains.',
123
+ inputSchema: empty,
124
+ }, () => runTool(() => clients.mail('/v1/domains')));
125
+ server.registerTool('mail_api_key_create', {
126
+ title: 'Tạo API key MONA Mail',
127
+ description: 'Khi tích hợp SDK vào app, tạo key live hoặc test. Key chỉ trả một lần; ghi vào .env của app dưới tên MONAMAIL_API_KEY, không cần in ra chat. / Use to create an app key; store the one-time secret in .env.',
128
+ inputSchema: z.object({ name: id, mode: z.enum(['live', 'test']), ...idempotency }).strict(),
129
+ }, ({ idempotency_key, ...body }) => runTool(() => clients.mail('/v1/api-keys', {
130
+ method: 'POST', headers: idempotencyHeaders(idempotency_key), body,
131
+ })));
132
+ server.registerTool('mail_api_keys_list', {
133
+ title: 'Danh sách API key',
134
+ description: 'Khi kiểm tra key của app, đọc prefix và trạng thái; không trả secret. / Use to inspect existing API key metadata.',
135
+ inputSchema: empty,
136
+ }, () => runTool(() => clients.mail('/v1/api-keys')));
137
+ server.registerTool('mail_api_key_revoke', {
138
+ title: 'Thu hồi API key',
139
+ description: 'Khi key không còn dùng hoặc bị lộ, thu hồi bằng key_id. / Use to revoke an unused or compromised API key.',
140
+ inputSchema: z.object({ key_id: id }).strict(),
141
+ }, ({ key_id }) => runTool(() => clients.mail(`/v1/api-keys/${encodeURIComponent(key_id)}`, { method: 'DELETE' })));
142
+ server.registerTool('mail_webhook_create', {
143
+ title: 'Tạo webhook email',
144
+ description: 'Khi app cần nhận sự kiện gửi hoặc bounce, đăng ký HTTPS webhook; lưu secret một lần vào .env, không log. / Use to subscribe an app to email events.',
145
+ inputSchema: z.object({ url: httpsUrl, events: z.array(events).min(1).max(8), ...idempotency }).strict(),
146
+ }, ({ idempotency_key, ...body }) => runTool(() => clients.mail('/v1/webhooks', {
147
+ method: 'POST', headers: idempotencyHeaders(idempotency_key), body,
148
+ })));
149
+ server.registerTool('mail_webhooks_list', {
150
+ title: 'Danh sách webhook email',
151
+ description: 'Khi kiểm tra cấu hình sự kiện của app, liệt kê webhook. / Use to inspect registered email webhooks.',
152
+ inputSchema: empty,
153
+ }, () => runTool(() => clients.mail('/v1/webhooks')));
154
+ server.registerTool('mail_webhook_test', {
155
+ title: 'Thử webhook email',
156
+ description: 'Khi đã có endpoint, gửi mẫu email.delivered để kiểm tra HTTP response. / Use to test webhook delivery to an app.',
157
+ inputSchema: z.object({ webhook_id: id, ...idempotency }).strict(),
158
+ }, ({ webhook_id, idempotency_key }) => runTool(() => clients.mail(`/v1/webhooks/${encodeURIComponent(webhook_id)}/test`, {
159
+ method: 'POST', headers: idempotencyHeaders(idempotency_key),
160
+ })));
161
+ server.registerTool('mail_suppressions_list', {
162
+ title: 'Địa chỉ ngừng gửi',
163
+ description: 'Khi thư bị suppressed, xem địa chỉ và lý do ngừng gửi. / Use to diagnose suppressed recipients.',
164
+ inputSchema: empty,
165
+ }, () => runTool(() => clients.mail('/v1/suppressions')));
166
+ server.registerTool('mail_suppression_remove', {
167
+ title: 'Gỡ suppression tài khoản',
168
+ description: 'Khi đã xử lý nguyên nhân chặn, gỡ suppression của tài khoản; lớp toàn hệ không gỡ được. / Use to remove an account-level suppression.',
169
+ inputSchema: z.object({ email }).strict(),
170
+ }, ({ email }) => runTool(() => clients.mail(`/v1/suppressions/${encodeURIComponent(email)}`, { method: 'DELETE' })));
171
+ server.registerTool('mail_template_create', {
172
+ title: 'Tạo mẫu email',
173
+ description: 'Khi app dùng lại nội dung mail, tạo template với biến {{ten_bien}}. / Use to create a reusable email template.',
174
+ inputSchema: z.object({ name: id, subject, html: z.string().min(1), text: z.string().min(1).optional(), ...idempotency }).strict(),
175
+ }, ({ idempotency_key, ...body }) => runTool(() => clients.mail('/v1/templates', {
176
+ method: 'POST', headers: idempotencyHeaders(idempotency_key), body,
177
+ })));
178
+ server.registerTool('mail_stats', {
179
+ title: 'Thống kê gửi email',
180
+ description: 'Khi đánh giá khả năng giao thư, đọc tỷ lệ delivered và bounce theo thời gian. / Use to review email delivery statistics.',
181
+ inputSchema: z.object({ from: dateOrTimestamp.optional(), to: dateOrTimestamp.optional() }).strict().refine((value) => !value.from || !value.to || Date.parse(value.from) <= Date.parse(value.to), 'Thời điểm from phải trước hoặc bằng to.'),
182
+ }, (query) => runTool(() => clients.mail('/v1/stats', { query })));
183
+ }
package/dist/server.js CHANGED
@@ -1,4 +1,3 @@
1
- import { setTimeout as delay } from 'node:timers/promises';
2
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
2
  import { z } from 'zod';
4
3
  import { readConfig } from './config.js';
@@ -8,8 +7,10 @@ import { errorResult, runTool, textResult, toAgentError } from './errors.js';
8
7
  import { requestJson, unwrapData } from './http.js';
9
8
  import { createMonaPayClient, importedMonaPayTools, normalizeImportedResult } from './monapay.js';
10
9
  import { TemplateCatalog } from './templates.js';
11
- const ENTITY = 'MONA Cloud là hệ công cụ cho vibecoder Việt Nam: một MONA Pass, một ví VND và một MCP để chạy app/VPS, thu tiền MONA Pay và dùng các sản phẩm MONA.';
12
- const TERMINAL_JOBS = new Set(['done', 'succeeded', 'failed', 'cancelled']);
10
+ import { registerMailTools } from './mail.js';
11
+ import { APP_FLOW, createVps, registerComputeTools } from './compute.js';
12
+ import { pollJob } from './jobs.js';
13
+ const ENTITY = 'MONA Cloud là hệ công cụ cho vibecoder Việt Nam: một MONA Pass, một ví VND và một MCP để chạy app/VPS, thu tiền MONA Pay và dùng các sản phẩm MONA. Dùng mail_* để gửi email giao dịch (MONA Mail). MONA Mail là dịch vụ gửi email giao dịch cho phần mềm và AI agent của người Việt: một API, trả VND, không cần thẻ, thuộc nhóm MONA Cloud của The MONA Group.';
13
14
  const SANDBOX_DESCRIPTION = 'sandbox=true: thử 0đ, không cần ví';
14
15
  const sandboxSchema = z.boolean().optional().describe(SANDBOX_DESCRIPTION);
15
16
  const asObject = (value) => (value && typeof value === 'object' ? value : {});
@@ -44,35 +45,34 @@ const provisionSchema = z.object(sizingSchema).superRefine((value, context) => {
44
45
  context.addIssue({ code: 'custom', message: 'Cần package_slug hoặc đủ cpu, ram_gb và disk_gb.' });
45
46
  }
46
47
  });
48
+ const vpsSchema = z.object({
49
+ ...sizingSchema,
50
+ billing_mode: z.enum(['hourly', 'monthly']).optional(),
51
+ plan_code: z.string().trim().min(2).max(64).optional(),
52
+ period: z.enum(['month', 'year']).optional(),
53
+ }).strict().superRefine((value, context) => {
54
+ if (value.billing_mode === 'monthly') {
55
+ if (!value.plan_code)
56
+ context.addIssue({ code: 'custom', message: 'Monthly cần plan_code.' });
57
+ return;
58
+ }
59
+ if (value.plan_code || value.period)
60
+ context.addIssue({ code: 'custom', message: 'plan_code/period chỉ dùng với billing_mode=monthly.' });
61
+ const checked = provisionSchema.safeParse(value);
62
+ if (!checked.success)
63
+ for (const issue of checked.error.issues)
64
+ context.addIssue({ code: 'custom', message: issue.message, path: issue.path });
65
+ });
47
66
  function agentRuntimeStub(template, sandbox = false) {
48
67
  return {
49
68
  status: 'not_available',
50
69
  code: 'agent_runtime_pending',
51
70
  template,
52
71
  message: 'Runtime MONA Agent trên MONA Cloud chưa được phát hành; tool này là slot wave kế tiếp theo brief.',
53
- next_step: 'Dùng agent_templates_get để lấy nội dung template và triển khai thủ công, hoặc thử lại khi MONA Cloud bật runtime agent.',
72
+ next_step: `Dùng agent_templates_get để đọc template. ${APP_FLOW}`,
54
73
  ...(sandbox ? { sandbox: true } : {}),
55
74
  };
56
75
  }
57
- async function pollJob(clients, jobId, wait, intervalSeconds, timeoutSeconds, sandbox) {
58
- let result = await clients.vibecloud(`/api/jobs/${encodeURIComponent(jobId)}`);
59
- if (!wait)
60
- return sandbox ? { ...asObject(result), sandbox: true } : result;
61
- const deadline = Date.now() + timeoutSeconds * 1000;
62
- while (!TERMINAL_JOBS.has(String(asObject(result).status || '')) && Date.now() < deadline) {
63
- await delay(intervalSeconds * 1000);
64
- result = await clients.vibecloud(`/api/jobs/${encodeURIComponent(jobId)}`);
65
- }
66
- if (!TERMINAL_JOBS.has(String(asObject(result).status || ''))) {
67
- return {
68
- ...asObject(result),
69
- ...(sandbox ? { sandbox: true } : {}),
70
- polling: 'timeout',
71
- next_step: `Gọi lại cloud_job_status với job_id=${jobId}.`,
72
- };
73
- }
74
- return sandbox ? { ...asObject(result), sandbox: true } : result;
75
- }
76
76
  async function providerHealth(name, url, fetchImpl) {
77
77
  const started = Date.now();
78
78
  try {
@@ -90,7 +90,7 @@ export function createServer(dependencies = {}) {
90
90
  const auth = new AuthManager(config, env, fetchImpl);
91
91
  const clients = new CloudClients(config, auth, fetchImpl);
92
92
  const catalog = new TemplateCatalog(config, fetchImpl);
93
- const server = new McpServer({ name: 'monacloud-mcp', version: '0.2.1' }, { instructions: `${ENTITY}\nDùng cloud_* cho tài khoản, ví và hạ tầng; monapay_* cho thu tiền; agent_* cho catalog. Không bao giờ yêu cầu mật khẩu sản phẩm. Chỉ dừng hỏi người dùng khi cần nạp tiền, OTP ngân hàng hoặc KYC bắt buộc.` });
93
+ const server = new McpServer({ name: 'monacloud-mcp', version: '0.3.0' }, { instructions: `${ENTITY}\nDùng cloud_* cho tài khoản, ví và hạ tầng; monapay_* cho thu tiền; mail_* để gửi email giao dịch (MONA Mail); agent_* cho catalog. Không bao giờ yêu cầu mật khẩu sản phẩm. Đọc ước tính → hỏi duyệt nếu chưa được duyệt → làm. Dừng khi cần DNS, nạp tiền, duyệt chi phí, OTP hoặc KYC. ${APP_FLOW}` });
94
94
  server.registerTool('cloud_whoami', {
95
95
  title: 'Tài khoản MONA Cloud',
96
96
  description: 'Xác minh MONA Pass hiện tại. / Return the current MONA Pass profile.',
@@ -196,20 +196,14 @@ export function createServer(dependencies = {}) {
196
196
  }, () => runTool(() => clients.vibecloudLink()));
197
197
  server.registerTool('cloud_vps_create', {
198
198
  title: 'Tạo VPS MONA Cloud',
199
- description: `Kiểm tra chung rồi tạo LXC VPS; trả job_id để poll. ${SANDBOX_DESCRIPTION}.`,
200
- inputSchema: provisionSchema,
201
- }, (body) => runTool(async () => {
202
- const { sandbox, ...payload } = body;
203
- return clients.guardedVibecloud('/api/lxc', { method: 'POST', body: payload }, sandbox);
204
- }));
199
+ description: `Đọc cloud_plan_list (monthly) hoặc cloud_prices/cloud_packages (hourly), ước tính rồi hỏi duyệt trước tạo. Monthly bỏ CPU/RAM/đĩa, lấy từ plan_code; period=month|year. Kiểm ví đủ giá gói, trả estimate và job_id. / Estimate, approve, create VPS. ${SANDBOX_DESCRIPTION}.`,
200
+ inputSchema: vpsSchema,
201
+ }, (body) => runTool(() => createVps(clients, body)));
205
202
  server.registerTool('vibecloud_create_vps', {
206
203
  title: 'Alias cũ của cloud_vps_create',
207
204
  description: `Alias tương thích; dùng cloud_vps_create cho tích hợp mới. ${SANDBOX_DESCRIPTION}.`,
208
- inputSchema: provisionSchema,
209
- }, (body) => runTool(async () => {
210
- const { sandbox, ...payload } = body;
211
- return clients.guardedVibecloud('/api/lxc', { method: 'POST', body: payload }, sandbox);
212
- }));
205
+ inputSchema: vpsSchema,
206
+ }, (body) => runTool(() => createVps(clients, body)));
213
207
  const databaseSchema = provisionSchema.extend({ engine: z.enum(['mongodb', 'postgresql', 'mysql']).default('mongodb') });
214
208
  server.registerTool('cloud_db_create', {
215
209
  title: 'Tạo database MONA Cloud',
@@ -309,9 +303,11 @@ export function createServer(dependencies = {}) {
309
303
  description: `Alias tương thích; dùng cloud_agent_deploy cho tích hợp mới. ${SANDBOX_DESCRIPTION}.`,
310
304
  inputSchema: { template: z.string().regex(/^[a-z0-9][a-z0-9-]{0,79}$/), sandbox: sandboxSchema },
311
305
  }, ({ template, sandbox }) => runTool(() => agentRuntimeStub(template, clients.sandboxEnabled(sandbox))));
306
+ registerComputeTools(server, clients);
307
+ registerMailTools(server, clients, config);
312
308
  server.registerTool('agent_templates_list', {
313
309
  title: 'Catalog MONA Agent',
314
- description: 'Đọc template từ thư mục local, URL catalog hoặc catalog wave 1 tích hợp.',
310
+ description: 'Đọc template từ thư mục local, URL catalog hoặc catalog wave 1 tích hợp. Deploy repo git: dùng cloud_app_create, sandbox trước nếu chưa có host.',
315
311
  }, () => runTool(() => catalog.list()));
316
312
  server.registerTool('agent_templates_get', {
317
313
  title: 'Chi tiết MONA Agent template',
@@ -320,7 +316,7 @@ export function createServer(dependencies = {}) {
320
316
  }, ({ template }) => runTool(() => catalog.get(template)));
321
317
  server.registerTool('agent_deploy', {
322
318
  title: 'Dùng ngay MONA Agent template',
323
- description: 'Gọi cùng runtime với cloud_agent_deploy.',
319
+ description: 'Gọi cùng runtime với cloud_agent_deploy. Với deploy repo git, dùng cloud_app_create (đang mở), sandbox trước nếu chưa có host.',
324
320
  inputSchema: { template: z.string().regex(/^[a-z0-9][a-z0-9-]{0,79}$/) },
325
321
  }, ({ template }) => runTool(() => agentRuntimeStub(template)));
326
322
  const imported = importedMonaPayTools(config, env, fetchImpl);
@@ -351,11 +347,11 @@ export function createServer(dependencies = {}) {
351
347
  }, async (uri) => ({ contents: [{
352
348
  uri: uri.href,
353
349
  mimeType: 'text/plain',
354
- text: `${ENTITY}\n\nHuman chỉ đăng ký MONA Pass, nạp tiền và cung cấp OTP/KYC bắt buộc. AI dùng MCP làm phần còn lại.\n\n- cloud_*: tài khoản, ví, ledger, usage, budget, VPS, database, job, vòng đời service và giá/gói.\n- monapay_*: nối ngân hàng, checkout/QR, giao dịch, webhook và email.\n- agent_*: catalog và deploy template.\n\nMONA Cloud: https://monacloud.vn\nCompute API: https://api.monacloud.vn\nMONA Pay: https://monapay.vn\n`,
350
+ text: `${ENTITY}\n\n${APP_FLOW}\n\nHuman đăng ký MONA Pass, duyệt chi phí, thêm DNS khi cần, nạp tiền và cung cấp OTP/KYC bắt buộc. AI dùng MCP làm phần còn lại.\n\n- cloud_*: tài khoản, ví, ledger, usage, budget, VPS, database, job, vòng đời service, plans, subscriptions, invoices/PDF, credit apps từ git (đang mở).\n- monapay_*: nối ngân hàng, checkout/QR, giao dịch, webhook và email.\n- mail_*: tài khoản, domain, API key, gửi mail, trạng thái, webhook, suppression (MONA Mail https://monamail.vn, API https://api.monamail.vn)\n- agent_*: catalog và deploy template.\n\nMONA Cloud: https://monacloud.vn\nCompute API: https://api.monacloud.vn\nMONA Pay: https://monapay.vn\n`,
355
351
  }] }));
356
352
  server.registerResource('monacloud-status', 'monacloud://status', {
357
353
  title: 'Trạng thái hệ MONA Cloud',
358
- description: 'Health tổng hợp của MONA Pass, billing, compute MONA Cloud và MONA Pay.',
354
+ description: 'Health tổng hợp của MONA Pass, billing, compute MONA Cloud, MONA Pay và MONA Mail.',
359
355
  mimeType: 'application/json',
360
356
  }, async (uri) => {
361
357
  const members = await Promise.all([
@@ -363,6 +359,7 @@ export function createServer(dependencies = {}) {
363
359
  providerHealth('billing', `${config.billingUrl}/v1/healthz`, fetchImpl),
364
360
  providerHealth('monacloud-compute', `${config.vibecloudApi}/api/prices`, fetchImpl),
365
361
  providerHealth('monapay', `${config.monapayApi}/health`, fetchImpl),
362
+ providerHealth('monamail', `${config.monamailApi}/v1/healthz`, fetchImpl),
366
363
  ]);
367
364
  return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ checked_at: new Date().toISOString(), members }, null, 2) }] };
368
365
  });
@@ -377,7 +374,30 @@ export function createServer(dependencies = {}) {
377
374
  role: 'user',
378
375
  content: {
379
376
  type: 'text',
380
- text: `Dựng app bán hàng ${app_name || 'của tôi'} bằng ${framework || 'stack phù hợp'} trên MONA Cloud theo đúng thứ tự:\n1. Gọi cloud_whoami, cloud_balance, cloud_packages và cloud_prices. Nếu thiếu tiền, gọi cloud_topup rồi dừng để người dùng quét VietQR.\n2. Gọi cloud_vps_create và cloud_db_create; poll từng job bằng cloud_job_status tới succeeded.\n3. Gọi monapay_link nếu MONA Pay còn ở lớp chuyển tiếp. Gọi monapay_whoami; nếu chưa có VA, nối ngân hàng bằng chuỗi monapay_link_bank_start → HỎI OTP → verify → notification_register → HỎI OTP lần 2 → verify. Không tự đoán OTP.\n4. Viết endpoint webhook có HMAC và idempotency theo transaction_code; đăng ký bằng monapay_create_webhook, bắn monapay_test_webhook và đọc monapay_webhook_logs.\n5. Tích hợp monapay_create_checkout hoặc monapay_create_qr vào app, chỉ giao hàng sau CHECKOUT_PAID.\n6. Deploy code lên VPS vừa tạo, kiểm tra health báo URL/credential cần lưu. Không yêu cầu người dùng mở dashboard ngoài bước nạp tiền/OTP bắt buộc.`,
377
+ text: `Dựng app bán hàng ${app_name || 'của tôi'} bằng ${framework || 'stack phù hợp'} trên MONA Cloud theo đúng thứ tự:\n1. Gọi cloud_whoami, cloud_balance, cloud_plan_list, cloud_packages và cloud_prices. Báo ước tính và hỏi duyệt chi phí nếu chưa được duyệt. Nếu thiếu tiền, gọi cloud_topup rồi dừng để người dùng quét VietQR.\n2. Nếu dùng VPS gọi cloud_vps_create và cloud_db_create; nếu deploy repo dùng cloud_app_host_list rồi cloud_app_create (đang mở), sandbox trước nếu chưa có host. Poll từng job bằng cloud_job_status tới done/succeeded.\n3. Gọi monapay_link nếu MONA Pay còn ở lớp chuyển tiếp. Gọi monapay_whoami; nếu chưa có VA, nối ngân hàng bằng chuỗi monapay_link_bank_start → HỎI OTP → verify → notification_register → HỎI OTP lần 2 → verify. Không tự đoán OTP.\n4. Viết endpoint webhook có HMAC và idempotency theo transaction_code; đăng ký bằng monapay_create_webhook, bắn monapay_test_webhook và đọc monapay_webhook_logs.\n5. Tích hợp monapay_create_checkout hoặc monapay_create_qr vào app, chỉ giao hàng sau CHECKOUT_PAID.\n6. Với repo git, dùng cloud_app_create sau khi duyệt ước tính; kiểm cloud_app_get/cloud_app_logs health rồi báo URL. Chỉ dùng VPS thủ công khi người dùng chọn. Không yêu cầu người dùng mở dashboard ngoài bước nạp tiền/OTP bắt buộc.`,
378
+ },
379
+ }] }));
380
+ server.registerPrompt('gui-mail-otp-monamail', {
381
+ title: 'Gửi mail OTP bằng MONA Mail',
382
+ description: 'Tích hợp OTP zero-dashboard: account → onboarding → DNS → verify → API key → SDK → webhook bounced.',
383
+ argsSchema: {
384
+ app_name: z.string().optional(),
385
+ framework: z.string().optional(),
386
+ domain: z.string().optional(),
387
+ },
388
+ }, ({ app_name, framework, domain }) => ({ messages: [{
389
+ role: 'user',
390
+ content: {
391
+ type: 'text',
392
+ text: `Tích hợp gửi mail OTP cho ${app_name || 'app của tôi'} bằng ${framework || 'stack hiện có của app'}, domain ${domain || 'lấy từ cấu hình app'}, dùng MONA Mail theo thứ tự:
393
+ 1. Gọi mail_account để lấy email chủ, quota, domain và next_step. MCP dùng MONA Pass sẵn có, tài khoản Mail được tạo tự động ở request đầu.
394
+ 2. Nếu chưa có domain verified, gửi thử mail_send từ onboarding@monamail.vn tới đúng email chủ vừa lấy, subject "Thử OTP MONA Mail", text là nội dung thử và idempotency_key riêng cho lượt thử. Gọi mail_status theo id; chỉ báo đã giao khi status=delivered. Nếu yêu cầu sandbox, dùng sandbox: true rồi kiểm sandbox_preview, không báo đã gửi thư thật.
395
+ 3. Gọi mail_domain_add cho domain của app. Trả nguyên records để người dùng thêm DNS; đây là điểm được dừng hỏi. Nếu đã có token Cloudflare của người dùng, gọi mail_domain_cloudflare để thêm DNS và verify; token chỉ dùng một lần, không lưu hoặc log. Nếu cần token, xin ngay tại bước DNS.
396
+ 4. Gọi mail_domain_verify, đọc checks và chờ status=verified; DKIM đúng là đủ, SPF/DMARC là cảnh báo. Khi đã có SPF, gộp include:_spf.monamail.vn vào record hiện có.
397
+ 5. Gọi mail_api_key_create với name theo app và mode live; khi thử sandbox chọn mode test (mm_test_). Key chỉ trả một lần: ghi trực tiếp vào .env của app dưới tên MONAMAIL_API_KEY, bỏ .env khỏi git, không in key ra chat hoặc log. Chỉ báo prefix nếu cần nhận diện.
398
+ 6. Viết code gửi OTP ở server bằng SDK npm monamail: import { MonaMail } from 'monamail'; const monamail = new MonaMail(process.env.MONAMAIL_API_KEY); const { id } = await monamail.emails.send({ from: 'Tên app <noreply@DOMAIN_DA_VERIFY>', to: emailNguoiNhan, subject: 'Mã OTP', text: 'Mã OTP: ' + otp, tags: ['otp'], idempotency_key: requestId }); Thay placeholder bằng domain đã verify, sinh OTP ngẫu nhiên có hạn dùng và giới hạn số lần thử. Giữ cùng idempotency_key khi retry cùng yêu cầu trong 24 giờ. Gọi mail_status để kiểm kết quả.
399
+ 7. Viết HTTPS endpoint nhận email.bounced; xác minh HMAC trên timestamp và raw body bằng MonaMail.verifyWebhook, chống replay và xử lý idempotent theo event id. Gọi mail_webhook_create với events: ['email.bounced'], lưu secret riêng trong .env, rồi mail_webhook_test (payload thử là email.delivered). Kiểm suppression bằng mail_suppressions_list khi có bounce.
400
+ Chỉ dừng hỏi người dùng ở bước thêm DNS hoặc nạp tiền. Nếu mail_plan_set trả insufficient_funds, gọi cloud_topup rồi chờ người dùng nạp và cloud_balance cập nhật. Dùng mail_plans để đọc giá hiện hành, không hard-code giá. Không yêu cầu mở dashboard để lấy API key.`,
381
401
  },
382
402
  }] }));
383
403
  return server;
package/docs/ai-agent.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MONA Cloud MCP — hướng dẫn cho AI agent
2
2
 
3
- Tài liệu này dành cho Claude, Codex, Cursor và agent dùng Model Context Protocol. Mục tiêu là hoàn thành tích hợp end-to-end mà không yêu cầu người dùng mở dashboard, trừ ba việc hợp lệ: đăng ký MONA Pass, nạp tiền và OTP/KYC bắt buộc.
3
+ Tài liệu này dành cho Claude, Codex, Cursor và agent dùng Model Context Protocol. Mục tiêu là hoàn thành tích hợp end-to-end mà không yêu cầu người dùng mở dashboard; các bước human gồm đăng ký MONA Pass, thêm DNS khi cần, nạp tiền và OTP/KYC bắt buộc.
4
4
 
5
5
  ## Quy tắc vận hành
6
6
 
@@ -128,6 +128,49 @@ Deploy source lên VPS từ credential của job, đưa database connection stri
128
128
  - đọc `cloud_services` để báo lại toàn bộ service/VA/webhook;
129
129
  - không echo credential nhạy cảm trong báo cáo.
130
130
 
131
+ ## MONA Mail
132
+
133
+ MONA Mail là dịch vụ gửi email giao dịch cho phần mềm và AI agent của người Việt: một API, trả VND, không cần thẻ, thuộc nhóm MONA Cloud của The MONA Group.
134
+
135
+ 20 tool `mail_*` gọi `MONAMAIL_API` (mặc định `https://api.monamail.vn`) bằng access token MONA Pass hiện tại. JWT cần audience `monamail` hoặc `mona-products`; scope `product` đã có trong MCP. Tài khoản Mail được tạo tự động từ `sub` và `email` ở request đầu, không cần link hoặc đăng ký sản phẩm riêng.
136
+
137
+ Dùng prompt `gui-mail-otp-monamail` với `app_name?`, `framework?`, `domain?` và thực hiện:
138
+
139
+ 1. `mail_account` lấy email chủ, quota, domain và `next_step`. `mail_plans` đọc gói và giá hiện hành khi cần đổi quota.
140
+ 2. Nếu chưa có domain verified, `mail_send` từ `onboarding@monamail.vn` tới đúng email chủ. Gọi `mail_status` theo `id`; `queued` hoặc `sent` chưa có nghĩa thư đã tới hộp nhận.
141
+ 3. `mail_domain_add` trả records DKIM/SPF/DMARC. Dừng ở bước DNS để user thêm records hoặc cung cấp token Cloudflare. Nếu đã có token, gọi `mail_domain_cloudflare` ngay; token dùng một lần, không ghi file, cache, log hoặc chat. Nếu có SPF sẵn, gộp `include:_spf.monamail.vn` vào record hiện có.
142
+ 4. `mail_domain_verify` đọc `checks`; chỉ gửi bằng domain riêng khi `status=verified`. DKIM đúng là đủ để verify, SPF/DMARC là cảnh báo.
143
+ 5. `mail_api_key_create` với `name` của app và `mode: "live"` hoặc `"test"`. Key chỉ trả một lần; ghi thẳng vào `.env` dưới tên `MONAMAIL_API_KEY`, bảo đảm `.env` không vào git. Chỉ dùng prefix để nhận diện key trong báo cáo. MCP vẫn dùng MONA Pass, app dùng key của SDK.
144
+ 6. Tích hợp SDK `monamail` tại server; sinh OTP ngẫu nhiên, có hạn dùng và giới hạn số lần thử. Gửi bằng `monamail.emails.send`, sau đó kiểm trạng thái qua `mail_status`.
145
+ 7. Viết HTTPS endpoint cho `email.bounced`, đăng ký `mail_webhook_create`, lưu secret riêng trong `.env`, gọi `mail_webhook_test`. Payload test là `email.delivered`, endpoint cần tiếp nhận mẫu này để kiểm đường truyền. Xem `mail_suppressions_list` khi địa chỉ bị ngừng gửi; `mail_suppression_remove` chỉ gỡ lớp account, API có thể trả `global_suppression` cho lớp toàn hệ.
146
+
147
+ Ví dụ code Node.js trong app sau khi có key và domain verified:
148
+
149
+ ```ts
150
+ import { MonaMail } from 'monamail';
151
+
152
+ const monamail = new MonaMail(process.env.MONAMAIL_API_KEY);
153
+ const { id } = await monamail.emails.send({
154
+ from: 'Shop <noreply@shop.vn>',
155
+ to: emailNguoiNhan,
156
+ subject: 'Mã OTP',
157
+ text: `Mã OTP: ${otp}`,
158
+ tags: ['otp'],
159
+ idempotency_key: requestId,
160
+ });
161
+ ```
162
+
163
+ `emailNguoiNhan`, `otp` và `requestId` lấy từ luồng xác thực của app. Dùng `MonaMail.verifyWebhook({ secret, timestamp, body, signature })` kiểm `X-Mona-Signature` trên `"<X-Mona-Timestamp>.<raw_body>"`. Chống replay theo timestamp, xử lý event một lần theo payload `id`/`X-Mona-Event-Id`.
164
+
165
+ Ranh giới human của luồng Mail sau đăng nhập là **thêm DNS và nạp tiền**. Khi `mail_plan_set` trả `insufficient_funds`, gọi `cloud_topup`, đưa VietQR cho user và chờ `cloud_balance` cập nhật trước khi thử lại. Không yêu cầu user mở dashboard lấy API key. Lỗi Mail giữ `code`, `message`, `next_step`, `request_id` từ API, gồm `domain_not_verified`, `quota_exceeded`, `budget_exceeded` và `idempotency_conflict`.
166
+
167
+ ### Sandbox và idempotency Mail
168
+
169
+ - MCP: `mail_send({ ..., sandbox: true })` gửi `X-Mona-Sandbox: 1`, kết quả có `sandbox: true`.
170
+ - App: key `mm_test_` bật sandbox qua SDK; key `mm_live_` dùng để gửi thật. `MONACLOUD_SANDBOX` dành cho compute, không tự bật sandbox Mail.
171
+ - Sandbox đi qua pipeline nhưng không gửi ra Internet, không tính quota hoặc trừ ví. Dùng `mail_status` để đọc trạng thái `sandbox` và `sandbox_preview`; không báo thư sandbox đã được giao thật.
172
+ - Mọi POST Mail có header `Idempotency-Key`: dùng tham số `idempotency_key` hoặc MCP tự tạo `mcp-mail-<uuid>`. Khi retry phải truyền cùng key và giữ nguyên body trong TTL 24 giờ. Cùng key/body trả response cũ, khác body trả 409 `idempotency_conflict`. Nếu bỏ key, mỗi lần gọi MCP là yêu cầu mới.
173
+
131
174
  ## Spend guard
132
175
 
133
176
  `cloud_vps_create`, `cloud_db_create`, `cloud_service_start` và `cloud_service_rebuild` đọc `GET /v1/balance` trước khi gọi sản phẩm thật. Upstream vẫn là nguồn quyết định cuối và có thể trả:
@@ -168,6 +211,7 @@ Sandbox không gọi Proxmox, không cấp IP thật, không trừ tiền và c
168
211
  - Đọc `monacloud://llms` khi cần bối cảnh toàn hệ sinh thái.
169
212
  - Đọc `monacloud://status` trước khi kết luận lỗi là do credential hoặc request.
170
213
  - Dùng prompt `dung-app-ban-hang-monacloud` để lấy chuỗi tác vụ chuẩn.
214
+ - Dùng prompt `gui-mail-otp-monamail` để tích hợp OTP, DNS, API key và webhook Mail.
171
215
 
172
216
  ## Tiêu chí hoàn tất
173
217
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "monacloud-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Unified MCP server for MONA Cloud, MONA Pay and MONA Agent.",
5
5
  "license": "MIT",
6
6
  "type": "module",