@capimail/mcp 0.0.0-stage → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-11-08
4
+
5
+ First release.
6
+
7
+ - stdio MCP server built on `@modelcontextprotocol/sdk` and `@capimail/sdk` 0.2.
8
+ - Read tools: `search_docs`, `get_integration_guide`, `get_credits`, `get_email_status`, `check_suppression`, `list_validation_jobs`, `get_validation_job`, `get_validation_results`, `list_domain_monitors`.
9
+ - Write tools: `send_email`, `send_otp`, `verify_otp`, `validate_emails`, `start_validation_job`, `check_domain`, `monitor_domain`, `add_suppression`.
10
+ - Prompt `integrate-capimail`; resource `capimail://docs/full`.
11
+ - Safety defaults:
12
+ - Live keys are read-only unless `CAPIMAIL_MCP_ACCESS=write`.
13
+ - `send_email` is capped at 5 recipients per call.
14
+ - Every send gets an idempotency key.
15
+ - API errors come back as readable tool results.
package/README.md CHANGED
@@ -1,3 +1,98 @@
1
- # Temporary Holding Version
1
+ # @capimail/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [Model Context Protocol](https://modelcontextprotocol.io) server for **CapiMail**. It lets AI coding agents and assistants (Claude Code, Cursor, VS Code Copilot, Windsurf, Claude Desktop) read the CapiMail docs, inspect your workspace, and, when you allow it, send transactional email, OTPs and run the Email Validation and Domain Health add-ons.
4
+
5
+ Runs locally over stdio and calls the public CapiMail API with your key. No other data leaves your machine.
6
+
7
+ ## Quick start
8
+
9
+ Start with a **test key** (`cm_test_…`, from Dashboard > API Keys). Every tool is available and nothing is delivered or charged.
10
+
11
+ ### Claude Code
12
+
13
+ ```bash
14
+ claude mcp add capimail --env CAPIMAIL_API_KEY=cm_test_your_key -- npx -y @capimail/mcp
15
+ ```
16
+
17
+ ### Cursor (`.cursor/mcp.json`) and Windsurf (`~/.codeium/windsurf/mcp_config.json`)
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "capimail": {
23
+ "command": "npx",
24
+ "args": ["-y", "@capimail/mcp"],
25
+ "env": { "CAPIMAIL_API_KEY": "cm_test_your_key" }
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ ### VS Code (`.vscode/mcp.json`)
32
+
33
+ The key is prompted once and never written to the file:
34
+
35
+ ```json
36
+ {
37
+ "inputs": [{ "type": "promptString", "id": "capimail-key", "description": "CapiMail API key", "password": true }],
38
+ "servers": {
39
+ "capimail": {
40
+ "type": "stdio",
41
+ "command": "npx",
42
+ "args": ["-y", "@capimail/mcp"],
43
+ "env": { "CAPIMAIL_API_KEY": "${input:capimail-key}" }
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ ### Claude Desktop (`claude_desktop_config.json`)
50
+
51
+ Same `mcpServers` block as Cursor.
52
+
53
+ ## Tools
54
+
55
+ | Tool | Access | What it does |
56
+ | --- | --- | --- |
57
+ | `search_docs` | read | Searches the CapiMail reference (endpoints, fields, errors, webhooks, add-ons). |
58
+ | `get_integration_guide` | read | Integration checklist plus a TypeScript, Python or cURL snippet. |
59
+ | `get_credits` | read | Balance, reserved and available credits. |
60
+ | `get_email_status` | read | Status and provider events of a message. |
61
+ | `check_suppression` | read | Whether an address is suppressed. |
62
+ | `list_validation_jobs`, `get_validation_job`, `get_validation_results` | read | Bulk validation jobs and per-address results. |
63
+ | `list_domain_monitors` | read | Monitored domains with their last DNS result. |
64
+ | `send_email` | write | Transactional email, inline or `template_key` (max 5 recipients per call). |
65
+ | `send_otp`, `verify_otp` | write | One-time codes using your OTP settings. |
66
+ | `validate_emails`, `start_validation_job` | write | Email Validation add-on (paid). |
67
+ | `check_domain`, `monitor_domain` | write | Domain Health add-on (paid, own domains free). |
68
+ | `add_suppression` | write | Add an address to the suppression list. |
69
+
70
+ Also available: the prompt **`integrate-capimail`** (guided setup for your codebase) and the resource **`capimail://docs/full`** (the full Markdown reference).
71
+
72
+ ## Safety defaults
73
+
74
+ - **Test keys** get read and write tools; sends are simulated and nothing is charged.
75
+ - **Live keys** get **read tools only**. Set `CAPIMAIL_MCP_ACCESS=write` to allow real sends and paid calls, and keep tool-call approval on in your client.
76
+ - No bulk or marketing tools. `send_email` is capped at `CAPIMAIL_MCP_MAX_RECIPIENTS` (default 5). Campaigns stay in the dashboard.
77
+ - All API rules still apply: verified domains, suppression, rate limits, credits.
78
+ - Do not commit config files that contain a key.
79
+
80
+ ## Configuration
81
+
82
+ | Variable | Default | Meaning |
83
+ | --- | --- | --- |
84
+ | `CAPIMAIL_API_KEY` | required | `cm_test_…` or `cm_live_…` |
85
+ | `CAPIMAIL_MCP_ACCESS` | `write` for test keys, `read` for live keys | `read` or `write` |
86
+ | `CAPIMAIL_MCP_MAX_RECIPIENTS` | `5` | Recipients per `send_email` call (1-50) |
87
+ | `CAPIMAIL_BASE_URL` | `https://capimail.com` | API origin |
88
+
89
+ ## Other sources for agents
90
+
91
+ - https://capimail.com/docs.md: the full reference as Markdown
92
+ - https://capimail.com/openapi.yaml: OpenAPI 3.1, every `/api/v1` route
93
+ - https://capimail.com/agents.md: a section for your repository's `AGENTS.md` (`curl -s https://capimail.com/agents.md >> AGENTS.md`)
94
+ - [`@capimail/sdk`](https://www.npmjs.com/package/@capimail/sdk): the typed TypeScript client this server uses
95
+
96
+ ## License
97
+
98
+ MIT
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Runtime configuration from environment variables. The server never logs or
3
+ * returns the API key.
4
+ *
5
+ * Access levels:
6
+ * - "read": status, credits, suppressions (read), validation job reads,
7
+ * domain monitor list, docs. Nothing that spends credits or sends email.
8
+ * - "write": everything, including sending email/OTP and paid add-on calls.
9
+ *
10
+ * Default: "write" for test keys (cm_test_, nothing is ever delivered or
11
+ * charged) and "read" for live keys. Set CAPIMAIL_MCP_ACCESS=write to let an
12
+ * agent send real email or spend credits with a live key.
13
+ */
14
+ export type Access = "read" | "write";
15
+ export type McpConfig = {
16
+ apiKey: string;
17
+ baseUrl: string;
18
+ mode: "test" | "live";
19
+ access: Access;
20
+ /** Hard cap on recipients per send_email call made by an agent. */
21
+ maxRecipients: number;
22
+ };
23
+ export declare class ConfigError extends Error {
24
+ }
25
+ export declare function loadConfig(env?: Record<string, string | undefined>): McpConfig;
package/dist/config.js ADDED
@@ -0,0 +1,22 @@
1
+ export class ConfigError extends Error {
2
+ }
3
+ export function loadConfig(env = process.env) {
4
+ const apiKey = env.CAPIMAIL_API_KEY?.trim();
5
+ if (!apiKey)
6
+ throw new ConfigError("Set CAPIMAIL_API_KEY to a CapiMail API key (cm_test_… to start safely, or cm_live_…).");
7
+ if (!/^cm_(test|live)_/.test(apiKey))
8
+ throw new ConfigError("CAPIMAIL_API_KEY must start with cm_test_ or cm_live_.");
9
+ const mode = apiKey.startsWith("cm_test_") ? "test" : "live";
10
+ const requested = env.CAPIMAIL_MCP_ACCESS?.trim().toLowerCase();
11
+ if (requested && requested !== "read" && requested !== "write")
12
+ throw new ConfigError("CAPIMAIL_MCP_ACCESS must be read or write.");
13
+ const access = requested ?? (mode === "test" ? "write" : "read");
14
+ const maxRecipients = Math.min(50, Math.max(1, Number(env.CAPIMAIL_MCP_MAX_RECIPIENTS ?? 5) || 5));
15
+ return {
16
+ apiKey,
17
+ baseUrl: (env.CAPIMAIL_BASE_URL?.trim() || "https://capimail.com").replace(/\/$/, ""),
18
+ mode,
19
+ access,
20
+ maxRecipients,
21
+ };
22
+ }
package/dist/docs.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Documentation access for coding agents: the full Markdown reference served
3
+ * at /llms-full.txt (same source as /docs), split into sections and searched
4
+ * by keyword. Cached per process for 10 minutes.
5
+ */
6
+ export type DocSection = {
7
+ title: string;
8
+ body: string;
9
+ };
10
+ export declare function fetchDocs(baseUrl: string, fetchImpl?: typeof fetch, now?: number): Promise<string>;
11
+ export declare function resetDocsCache(): void;
12
+ /** Splits on Markdown headings (## and ###), keeping each heading with its body. */
13
+ export declare function splitSections(markdown: string): DocSection[];
14
+ /** Ranks sections by query term hits (title hits weigh more) and returns the best ones. */
15
+ export declare function searchSections(sections: DocSection[], query: string, limit?: number): DocSection[];
package/dist/docs.js ADDED
@@ -0,0 +1,51 @@
1
+ const TTL_MS = 10 * 60_000;
2
+ let cache = null;
3
+ export async function fetchDocs(baseUrl, fetchImpl = fetch, now = Date.now()) {
4
+ if (cache && cache.baseUrl === baseUrl && now - cache.at < TTL_MS)
5
+ return cache.text;
6
+ const response = await fetchImpl(`${baseUrl}/llms-full.txt`, { headers: { accept: "text/plain" } });
7
+ if (!response.ok)
8
+ throw new Error(`Could not load CapiMail docs (HTTP ${response.status}).`);
9
+ const text = await response.text();
10
+ cache = { at: now, baseUrl, text };
11
+ return text;
12
+ }
13
+ export function resetDocsCache() {
14
+ cache = null;
15
+ }
16
+ /** Splits on Markdown headings (## and ###), keeping each heading with its body. */
17
+ export function splitSections(markdown) {
18
+ const sections = [];
19
+ let current = null;
20
+ for (const line of markdown.split("\n")) {
21
+ const heading = /^#{2,3}\s+(.*)$/.exec(line);
22
+ if (heading) {
23
+ if (current)
24
+ sections.push(current);
25
+ current = { title: heading[1].trim(), body: "" };
26
+ }
27
+ else if (current) {
28
+ current.body += `${line}\n`;
29
+ }
30
+ }
31
+ if (current)
32
+ sections.push(current);
33
+ return sections.map((section) => ({ ...section, body: section.body.trim() })).filter((section) => section.body.length > 0);
34
+ }
35
+ /** Ranks sections by query term hits (title hits weigh more) and returns the best ones. */
36
+ export function searchSections(sections, query, limit = 3) {
37
+ const terms = query.toLowerCase().split(/[^a-z0-9_./-]+/).filter((term) => term.length > 1);
38
+ if (!terms.length)
39
+ return sections.slice(0, limit);
40
+ return sections
41
+ .map((section, index) => {
42
+ const title = section.title.toLowerCase();
43
+ const body = section.body.toLowerCase();
44
+ const score = terms.reduce((sum, term) => sum + (title.includes(term) ? 5 : 0) + (body.split(term).length - 1), 0);
45
+ return { section, score, index };
46
+ })
47
+ .filter((entry) => entry.score > 0)
48
+ .sort((a, b) => b.score - a.score || a.index - b.index)
49
+ .slice(0, limit)
50
+ .map((entry) => entry.section);
51
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ // CapiMail MCP server (stdio). Configure your AI client to run:
3
+ // npx -y @capimail/mcp
4
+ // with CAPIMAIL_API_KEY in its environment. See README.md.
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { ConfigError, loadConfig } from "./config.js";
7
+ import { createServer } from "./server.js";
8
+ async function main() {
9
+ let config;
10
+ try {
11
+ config = loadConfig();
12
+ }
13
+ catch (error) {
14
+ // stdout belongs to the MCP protocol; diagnostics go to stderr.
15
+ console.error(`[capimail-mcp] ${error instanceof ConfigError ? error.message : "Invalid configuration."}`);
16
+ process.exit(1);
17
+ }
18
+ const server = createServer(config);
19
+ await server.connect(new StdioServerTransport());
20
+ console.error(`[capimail-mcp] ready (${config.mode} key, ${config.access} access, ${config.baseUrl})`);
21
+ }
22
+ main().catch((error) => {
23
+ console.error("[capimail-mcp] fatal:", error instanceof Error ? error.message : error);
24
+ process.exit(1);
25
+ });
@@ -0,0 +1,15 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { McpConfig } from "./config.js";
3
+ export declare const SERVER_NAME = "capimail";
4
+ export declare const SERVER_VERSION = "0.1.0";
5
+ /** Turns any thrown error into a readable tool result instead of a protocol error. */
6
+ export declare function errorResult(error: unknown): {
7
+ isError: boolean;
8
+ content: {
9
+ type: "text";
10
+ text: string;
11
+ }[];
12
+ };
13
+ export declare function createServer(config: McpConfig, options?: {
14
+ fetch?: typeof fetch;
15
+ }): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,65 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { CapiMail, CapiMailError } from "@capimail/sdk";
3
+ import { z } from "zod";
4
+ import { fetchDocs } from "./docs.js";
5
+ import { integrationGuide, toolsFor } from "./tools.js";
6
+ export const SERVER_NAME = "capimail";
7
+ export const SERVER_VERSION = "0.1.0";
8
+ /** Turns any thrown error into a readable tool result instead of a protocol error. */
9
+ export function errorResult(error) {
10
+ if (error instanceof CapiMailError) {
11
+ const hint = error.retryAfterSeconds ? ` Retry after ${error.retryAfterSeconds}s.` : "";
12
+ return { isError: true, content: [{ type: "text", text: `CapiMail API error ${error.status} ${error.code}: ${error.message}${hint}` }] };
13
+ }
14
+ return { isError: true, content: [{ type: "text", text: error instanceof Error ? error.message : "Unexpected error." }] };
15
+ }
16
+ export function createServer(config, options = {}) {
17
+ const client = new CapiMail({ apiKey: config.apiKey, baseUrl: config.baseUrl, fetch: options.fetch });
18
+ const context = { client, config, fetch: options.fetch };
19
+ const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, {
20
+ instructions: [
21
+ "CapiMail is a transactional email API for Indonesian businesses (also: Email Validation, OTP and Domain Health add-ons).",
22
+ "Before writing integration code, call search_docs or get_integration_guide.",
23
+ `This session uses a ${config.mode} key with ${config.access} access.`,
24
+ config.mode === "test" ? "Test key: sends are simulated, nothing is delivered and no credits are spent." : "Live key: write tools deliver real email and spend credits; confirm with the user first.",
25
+ ].join(" "),
26
+ });
27
+ for (const tool of toolsFor(config)) {
28
+ server.registerTool(tool.name, { title: tool.title, description: tool.description, inputSchema: tool.inputSchema, annotations: tool.annotations }, async (args) => {
29
+ try {
30
+ const result = await tool.handler(args, context);
31
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
32
+ }
33
+ catch (error) {
34
+ return errorResult(error);
35
+ }
36
+ });
37
+ }
38
+ server.registerResource("capimail-docs", "capimail://docs/full", { title: "CapiMail API reference (Markdown)", description: "Every endpoint, field, error code, webhook and add-on.", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: await fetchDocs(config.baseUrl, options.fetch) }] }));
39
+ server.registerPrompt("integrate-capimail", {
40
+ title: "Integrate CapiMail into this project",
41
+ description: "Guides the coding agent through adding transactional email with CapiMail.",
42
+ argsSchema: { use_case: z.string().optional().describe("e.g. email verification, OTP login, password reset, receipts") },
43
+ }, ({ use_case }) => {
44
+ const guide = integrationGuide(config.baseUrl, "typescript");
45
+ return {
46
+ messages: [{
47
+ role: "user",
48
+ content: {
49
+ type: "text",
50
+ text: [
51
+ `Add CapiMail transactional email to this project${use_case ? ` for: ${use_case}` : ""}.`,
52
+ "Follow this checklist exactly:",
53
+ ...guide.checklist.map((step, index) => `${index + 1}. ${step}`),
54
+ "",
55
+ "Reference snippet:",
56
+ guide.snippet,
57
+ "",
58
+ "Use the search_docs tool for any field, error code or webhook detail you are unsure about. Do not invent endpoints.",
59
+ ].join("\n"),
60
+ },
61
+ }],
62
+ };
63
+ });
64
+ return server;
65
+ }
@@ -0,0 +1,42 @@
1
+ import type { CapiMail } from "@capimail/sdk";
2
+ import { z } from "zod";
3
+ import type { Access, McpConfig } from "./config.js";
4
+ /**
5
+ * Every tool the CapiMail MCP server can expose. Each declares the access it
6
+ * needs: "read" tools never send email or spend credits; "write" tools do and
7
+ * are only registered when the configuration allows it (see config.ts).
8
+ * Handlers return plain JSON; the server turns them into MCP text content.
9
+ */
10
+ export type ToolContext = {
11
+ client: CapiMail;
12
+ config: McpConfig;
13
+ fetch?: typeof fetch;
14
+ };
15
+ export type ToolDefinition = {
16
+ name: string;
17
+ title: string;
18
+ description: string;
19
+ access: Access;
20
+ /** MCP tool annotations, shown to the user by most clients. */
21
+ annotations: {
22
+ readOnlyHint?: boolean;
23
+ destructiveHint?: boolean;
24
+ idempotentHint?: boolean;
25
+ openWorldHint?: boolean;
26
+ };
27
+ inputSchema: z.ZodRawShape;
28
+ handler: (args: Record<string, unknown>, context: ToolContext) => Promise<unknown>;
29
+ };
30
+ export declare const TOOLS: ToolDefinition[];
31
+ /** Tools available for a configuration: write tools only when access is "write". */
32
+ export declare function toolsFor(config: McpConfig): ToolDefinition[];
33
+ export declare function integrationGuide(baseUrl: string, language: string): {
34
+ checklist: string[];
35
+ snippet: string;
36
+ references: {
37
+ docs: string;
38
+ markdown: string;
39
+ openapi: string;
40
+ agents_md: string;
41
+ };
42
+ };
package/dist/tools.js ADDED
@@ -0,0 +1,272 @@
1
+ import { z } from "zod";
2
+ import { fetchDocs, searchSections, splitSections } from "./docs.js";
3
+ const email = z.string().trim().max(320);
4
+ const idempotencyKey = z.string().regex(/^[A-Za-z0-9._:-]{8,128}$/).optional()
5
+ .describe("Optional retry-safety key (8-128 of A-Z a-z 0-9 . _ : -). Reusing it never sends or charges twice.");
6
+ const READ = { readOnlyHint: true, openWorldHint: true };
7
+ export const TOOLS = [
8
+ // ---- Docs (no API key use) ------------------------------------------------
9
+ {
10
+ name: "search_docs",
11
+ title: "Search CapiMail docs",
12
+ description: "Search the CapiMail API documentation (endpoints, fields, errors, webhooks, add-ons, pricing). Use this before writing integration code.",
13
+ access: "read",
14
+ annotations: READ,
15
+ inputSchema: { query: z.string().min(2).max(200).describe("What you need, e.g. 'verify webhook signature' or 'send with template_key'.") },
16
+ handler: async (args, { config, fetch: fetchImpl }) => {
17
+ const sections = splitSections(await fetchDocs(config.baseUrl, fetchImpl));
18
+ const hits = searchSections(sections, String(args.query));
19
+ return {
20
+ results: hits.map((section) => ({ title: section.title, content: section.body.slice(0, 4000) })),
21
+ full_reference: `${config.baseUrl}/llms-full.txt`,
22
+ openapi: `${config.baseUrl}/openapi.yaml`,
23
+ };
24
+ },
25
+ },
26
+ {
27
+ name: "get_integration_guide",
28
+ title: "Get the integration checklist",
29
+ description: "Returns the step-by-step checklist and code conventions for adding CapiMail to an application (keys, domain, SDK, idempotency, webhooks, test mode).",
30
+ access: "read",
31
+ annotations: READ,
32
+ inputSchema: { language: z.enum(["typescript", "python", "curl"]).optional().describe("Preferred language for snippets (default typescript).") },
33
+ handler: async (args, { config }) => integrationGuide(config.baseUrl, args.language ?? "typescript"),
34
+ },
35
+ // ---- Account and email status ----------------------------------------------
36
+ {
37
+ name: "get_credits",
38
+ title: "Get credit balance",
39
+ description: "Returns the workspace's credit balance, reserved and available credits.",
40
+ access: "read",
41
+ annotations: READ,
42
+ inputSchema: {},
43
+ handler: async (_args, { client }) => client.account.credits(),
44
+ },
45
+ {
46
+ name: "get_email_status",
47
+ title: "Get email status",
48
+ description: "Returns the status and provider events of a sent email by message id.",
49
+ access: "read",
50
+ annotations: READ,
51
+ inputSchema: { message_id: z.string().uuid() },
52
+ handler: async (args, { client }) => client.emails.get(String(args.message_id)),
53
+ },
54
+ {
55
+ name: "check_suppression",
56
+ title: "Check if an address is suppressed",
57
+ description: "Returns whether CapiMail will refuse to send to an address (bounced, complained, unsubscribed or manually suppressed).",
58
+ access: "read",
59
+ annotations: READ,
60
+ inputSchema: { email },
61
+ handler: async (args, { client }) => client.suppressions.get(String(args.email)),
62
+ },
63
+ {
64
+ name: "list_validation_jobs",
65
+ title: "List validation jobs",
66
+ description: "Lists recent bulk Email Validation jobs with status and summary.",
67
+ access: "read",
68
+ annotations: READ,
69
+ inputSchema: { limit: z.number().int().min(1).max(100).optional() },
70
+ handler: async (args, { client }) => client.validation.jobs.list({ limit: args.limit }),
71
+ },
72
+ {
73
+ name: "get_validation_job",
74
+ title: "Get a validation job",
75
+ description: "Returns a bulk validation job's status and, once completed, its summary.",
76
+ access: "read",
77
+ annotations: READ,
78
+ inputSchema: { job_id: z.string().uuid() },
79
+ handler: async (args, { client }) => client.validation.jobs.get(String(args.job_id)),
80
+ },
81
+ {
82
+ name: "get_validation_results",
83
+ title: "Get validation results",
84
+ description: "Returns one page (up to 1000) of per-address results of a validation job, optionally filtered by verdict.",
85
+ access: "read",
86
+ annotations: READ,
87
+ inputSchema: {
88
+ job_id: z.string().uuid(),
89
+ verdict: z.enum(["valid", "risky", "invalid", "unknown"]).optional(),
90
+ after: z.number().int().optional().describe("The next_after value of the previous page."),
91
+ limit: z.number().int().min(1).max(1000).optional(),
92
+ },
93
+ handler: async (args, { client }) => client.validation.jobs.results(String(args.job_id), {
94
+ verdict: args.verdict,
95
+ after: args.after,
96
+ limit: args.limit,
97
+ }),
98
+ },
99
+ {
100
+ name: "list_domain_monitors",
101
+ title: "List monitored domains",
102
+ description: "Lists domains under daily DNS monitoring with their last MX/SPF/DKIM/DMARC result.",
103
+ access: "read",
104
+ annotations: READ,
105
+ inputSchema: {},
106
+ handler: async (_args, { client }) => ({ monitors: await client.domains.monitors.list() }),
107
+ },
108
+ // ---- Write: sends email or spends credits ----------------------------------
109
+ {
110
+ name: "send_email",
111
+ title: "Send a transactional email",
112
+ description: "Sends a transactional email from a verified domain (inline html/text or a stored template_key). With a test key nothing is delivered. Not for marketing or bulk sends.",
113
+ access: "write",
114
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
115
+ inputSchema: {
116
+ from: z.string().max(423).describe("Verified sender, e.g. 'Acme <hello@acme.co.id>'."),
117
+ to: z.array(email).min(1).describe("Recipients (capped by CAPIMAIL_MCP_MAX_RECIPIENTS, default 5)."),
118
+ subject: z.string().max(998).optional(),
119
+ html: z.string().max(200_000).optional(),
120
+ text: z.string().max(100_000).optional(),
121
+ template_key: z.string().optional().describe("Send a stored transactional template instead of subject/html/text."),
122
+ locale: z.string().optional(),
123
+ variables: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
124
+ idempotency_key: idempotencyKey,
125
+ },
126
+ handler: async (args, { client, config }) => {
127
+ const to = args.to;
128
+ if (to.length > config.maxRecipients)
129
+ throw new Error(`An agent may send to at most ${config.maxRecipients} recipients per call. Use the API or dashboard for larger sends.`);
130
+ return client.emails.send({
131
+ from: String(args.from),
132
+ to,
133
+ subject: args.subject,
134
+ html: args.html,
135
+ text: args.text,
136
+ idempotencyKey: args.idempotency_key ?? `mcp-${crypto.randomUUID()}`,
137
+ ...(args.template_key ? { template: { key: String(args.template_key), locale: args.locale, variables: args.variables } } : {}),
138
+ });
139
+ },
140
+ },
141
+ {
142
+ name: "validate_emails",
143
+ title: "Validate email addresses",
144
+ description: "Checks up to 100 addresses now (syntax, mail server, disposable, role). Costs 2 credits per address with a verdict; needs a live key.",
145
+ access: "write",
146
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
147
+ inputSchema: { emails: z.array(email).min(1).max(100), idempotency_key: idempotencyKey },
148
+ handler: async (args, { client }) => client.validation.check({ emails: args.emails, idempotencyKey: args.idempotency_key }),
149
+ },
150
+ {
151
+ name: "start_validation_job",
152
+ title: "Start a bulk validation job",
153
+ description: "Starts a background validation job for up to 10,000 addresses. Credits are charged up front (unknowns refunded); completion fires the validation.completed webhook.",
154
+ access: "write",
155
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
156
+ inputSchema: {
157
+ emails: z.array(email).min(1).max(10_000),
158
+ suppress_invalid: z.boolean().optional(),
159
+ metadata: z.record(z.string(), z.string()).optional(),
160
+ idempotency_key: idempotencyKey,
161
+ },
162
+ handler: async (args, { client }) => client.validation.jobs.create({
163
+ emails: args.emails,
164
+ suppressInvalid: args.suppress_invalid,
165
+ metadata: args.metadata,
166
+ idempotencyKey: args.idempotency_key,
167
+ }),
168
+ },
169
+ {
170
+ name: "send_otp",
171
+ title: "Send a one-time code",
172
+ description: "Creates and emails a one-time code using the workspace's OTP settings. Returns the OTP id (and test_code with a test key).",
173
+ access: "write",
174
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
175
+ inputSchema: {
176
+ to: email,
177
+ purpose: z.string().optional(),
178
+ domain: z.string().optional(),
179
+ template_key: z.string().optional(),
180
+ locale: z.string().optional(),
181
+ },
182
+ handler: async (args, { client }) => client.otp.send({
183
+ to: String(args.to),
184
+ purpose: args.purpose,
185
+ domain: args.domain,
186
+ templateKey: args.template_key,
187
+ locale: args.locale,
188
+ }),
189
+ },
190
+ {
191
+ name: "verify_otp",
192
+ title: "Verify a one-time code",
193
+ description: "Verifies a code once. Wrong codes count toward the lock limit, so only call it with what the user typed.",
194
+ access: "write",
195
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
196
+ inputSchema: { otp_id: z.string().uuid(), code: z.string().min(4).max(20) },
197
+ handler: async (args, { client }) => client.otp.verify(String(args.otp_id), String(args.code)),
198
+ },
199
+ {
200
+ name: "check_domain",
201
+ title: "Check a domain's email DNS",
202
+ description: "Live MX, SPF, DKIM and DMARC check with issue codes. Free for the workspace's verified sending domains, otherwise 5 credits.",
203
+ access: "write",
204
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
205
+ inputSchema: { domain: z.string().max(253), dkim_selectors: z.array(z.string().max(63)).max(5).optional(), idempotency_key: idempotencyKey },
206
+ handler: async (args, { client }) => client.domains.check({ domain: String(args.domain), dkimSelectors: args.dkim_selectors, idempotencyKey: args.idempotency_key }),
207
+ },
208
+ {
209
+ name: "monitor_domain",
210
+ title: "Monitor a domain daily",
211
+ description: "Adds a domain to daily DNS monitoring (3 credits per daily check). domain.health_changed fires when its status changes.",
212
+ access: "write",
213
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
214
+ inputSchema: { domain: z.string().max(253), dkim_selectors: z.array(z.string().max(63)).max(5).optional() },
215
+ handler: async (args, { client }) => client.domains.monitors.create({ domain: String(args.domain), dkimSelectors: args.dkim_selectors }),
216
+ },
217
+ {
218
+ name: "add_suppression",
219
+ title: "Suppress an address",
220
+ description: "Adds an address to the suppression list so CapiMail never sends to it.",
221
+ access: "write",
222
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
223
+ inputSchema: { email, reason: z.enum(["manual", "unsubscribe", "spam_complaint", "bounce", "other"]).optional() },
224
+ handler: async (args, { client }) => client.suppressions.add(String(args.email), args.reason),
225
+ },
226
+ ];
227
+ /** Tools available for a configuration: write tools only when access is "write". */
228
+ export function toolsFor(config) {
229
+ return TOOLS.filter((tool) => tool.access === "read" || config.access === "write");
230
+ }
231
+ export function integrationGuide(baseUrl, language) {
232
+ const snippets = {
233
+ typescript: `npm install @capimail/sdk
234
+
235
+ import { CapiMail } from "@capimail/sdk";
236
+ const capimail = new CapiMail({ apiKey: process.env.CAPIMAIL_API_KEY! });
237
+
238
+ await capimail.emails.send({
239
+ from: "Acme <hello@your-verified-domain.com>",
240
+ to: user.email,
241
+ template: { key: "email-verification", locale: "id", variables: { name: user.name, verify_url: link } },
242
+ idempotencyKey: \`verify-\${user.id}\`,
243
+ });`,
244
+ python: `import os, requests
245
+ requests.post("${baseUrl}/api/v1/emails",
246
+ headers={"Authorization": f"Bearer {os.environ['CAPIMAIL_API_KEY']}"},
247
+ json={"from": "hello@your-verified-domain.com", "to": ["user@example.com"],
248
+ "subject": "Welcome", "html": "<p>Hi</p>", "idempotency_key": "welcome-123"}, timeout=10)`,
249
+ curl: `curl -X POST ${baseUrl}/api/v1/emails \\
250
+ -H "Authorization: Bearer $CAPIMAIL_API_KEY" -H "Content-Type: application/json" \\
251
+ -d '{"from":"hello@your-verified-domain.com","to":["user@example.com"],"subject":"Welcome","html":"<p>Hi</p>","idempotency_key":"welcome-123"}'`,
252
+ };
253
+ return {
254
+ checklist: [
255
+ "Create an API key in the dashboard (API Keys). Start with cm_test_: full simulated lifecycle, nothing delivered, no credits.",
256
+ "Store it server-side as CAPIMAIL_API_KEY. Never expose it to the browser or commit it.",
257
+ "Verify a sending domain (Domains) and send only from addresses on it.",
258
+ "Send with POST /api/v1/emails or the SDK. Always pass an idempotency key derived from your own record id so retries never double-send.",
259
+ "A 202 means queued, not delivered. Track outcomes with webhooks (Settings > Webhooks) and verify X-CapiMail-Signature, or poll GET /api/v1/emails/{id}.",
260
+ "Handle errors by code: 402 insufficient_credits, 422 recipient_suppressed, 429 rate_limit_exceeded (respect Retry-After).",
261
+ "Prefer stored templates (template_key) for verification, OTP, reset and receipt emails; marketing content must use campaigns.",
262
+ "Switch to a cm_live_ key only after the organization is approved.",
263
+ ],
264
+ snippet: snippets[language] ?? snippets.typescript,
265
+ references: {
266
+ docs: `${baseUrl}/docs`,
267
+ markdown: `${baseUrl}/docs.md`,
268
+ openapi: `${baseUrl}/openapi.yaml`,
269
+ agents_md: `${baseUrl}/agents.md`,
270
+ },
271
+ };
272
+ }
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@capimail/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol server for CapiMail: lets AI coding agents and assistants read the docs, send transactional email, validate addresses, send/verify OTPs and check domain health.",
5
+ "author": "CapiMail",
6
+ "homepage": "https://capimail.com/docs#ai-agents",
7
+ "license": "MIT",
8
+ "type": "module",
9
+ "bin": {
10
+ "capimail-mcp": "./dist/index.js"
11
+ },
12
+ "main": "./dist/server.js",
13
+ "types": "./dist/server.d.ts",
14
+ "files": ["dist", "README.md", "CHANGELOG.md"],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json && chmod +x dist/index.js",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "dependencies": {
23
+ "@capimail/sdk": "^0.2.0",
24
+ "@modelcontextprotocol/sdk": "^1.32.2",
25
+ "zod": "^4.1.11"
26
+ },
27
+ "keywords": ["capimail", "mcp", "model-context-protocol", "email", "transactional", "otp", "ai-agent", "claude", "cursor"],
28
+ "engines": {
29
+ "node": ">=18"
30
+ }
31
+ }