diff --git a/lib/index.js b/lib/index.js index b9e740a..001b244 100644 --- a/lib/index.js +++ b/lib/index.js @@ -21,6 +21,7 @@ class McpServer { inputSchema: { type: 'object', properties: tool.toolSpec.input?.properties ?? {}, + required: tool.toolSpec.input?.required ?? [], }, })), })); diff --git a/lib/tools.js b/lib/tools.js index dc60519..bb3af1f 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -37,108 +37,203 @@ const getTools = (options = {}) => { // otherwise falls back to API key // 'none' – no authentication required // - const createTool = (spec) => ({ - toolSpec: { - name: spec.name, - description: spec.description, - input: { - type: 'object', - properties: { - ...spec.inputs, - // Inject alias credential inputs for alias-auth and both-auth tools - - ...((spec.auth === 'aliasAuth' || spec.auth === 'both') && { - alias_username: { - type: 'string', - description: - 'Alias email address for authentication (e.g. user@example.com). ' + - 'Required for alias-authenticated endpoints. ' + - 'Falls back to FORWARD_EMAIL_ALIAS_USER env var.', - }, - alias_password: { - type: 'string', - description: - 'Generated alias password for authentication. ' + - 'Required for alias-authenticated endpoints. ' + - 'Falls back to FORWARD_EMAIL_ALIAS_PASSWORD env var. ' + - 'Generate one with the generateAliasPassword tool.', - }, - }), + // Descriptions for well-known path parameters + const pathParameterDescriptions = { + domain_id: 'Domain ID or fully qualified domain name (e.g. "example.com")', + alias_id: 'Alias ID', + id: 'Resource ID', + token_id: 'Token ID for the catch-all password', + member_id: 'Member ID', + script_id: 'Sieve script ID', + }; + + // Descriptions for well-known query parameters + const queryParameterDescriptions = { + sort: 'Sort field and direction (e.g. "created_at" or "-created_at" for descending)', + page: 'Page number for pagination (1-based)', + limit: 'Number of results per page', + q: 'Search query string', + domain: 'Domain name to filter by', + bounce_category: 'Filter by bounce category', + response_code: 'Filter by SMTP response code', + always_send_email: + 'Whether to always send the log download via email (boolean)', + is_scheduled: 'Filter by scheduled status (boolean)', + folder: 'IMAP folder name (e.g. "INBOX", "Sent", "Drafts")', + is_unread: 'Filter by unread status (boolean)', + is_flagged: 'Filter by flagged status (boolean)', + is_deleted: 'Filter by deleted status (boolean)', + is_draft: 'Filter by draft status (boolean)', + is_junk: 'Filter by junk/spam status (boolean)', + is_copied: 'Filter by copied status (boolean)', + is_encrypted: 'Filter by encrypted status (boolean)', + is_searchable: 'Filter by searchable status (boolean)', + is_expired: 'Filter by expired status (boolean)', + has_attachments: 'Filter messages with attachments (boolean)', + has_attachment: 'Filter messages with attachments (boolean)', + subject: 'Filter by message subject', + body: 'Search within message body', + text: 'Full text search query', + headers: 'Search within message headers', + message_id: 'Filter by Message-ID header', + search: 'IMAP SEARCH query string', + since: 'Filter messages after this date (ISO 8601)', + before: 'Filter messages before this date (ISO 8601)', + min_size: 'Minimum message size in bytes', + max_size: 'Maximum message size in bytes', + from: 'Filter by sender address', + to: 'Filter by recipient address', + cc: 'Filter by CC address', + bcc: 'Filter by BCC address', + date: 'Filter by message date', + 'reply-to': 'Filter by Reply-To address', + eml: 'Return raw EML format (boolean)', + nodemailer: 'Return in Nodemailer-compatible format (boolean)', + attachments: 'Include attachments in response (boolean)', + raw: 'Return raw message source (boolean)', + subscribed: 'Filter by subscription status (boolean)', + }; + + const createTool = (spec) => { + // Auto-extract path parameter names from the URL template + const pathParameters = (spec.path.match(/{(\w+)}/g) || []).map((match) => + match.slice(1, -1), + ); + + // Auto-generate property definitions for path parameters + const pathProperties = {}; + for (const parameter of pathParameters) { + pathProperties[parameter] = { + type: 'string', + description: + pathParameterDescriptions[parameter] || + `${parameter.replaceAll('_', ' ')}`, + }; + } + + // Auto-generate property definitions for query parameters + const queryProperties = {}; + if (spec.query) { + for (const parameter of spec.query) { + queryProperties[parameter] = { + type: 'string', + description: + queryParameterDescriptions[parameter] || + `${parameter.replaceAll('_', ' ')}`, + }; + } + } + + return { + toolSpec: { + name: spec.name, + description: spec.description, + input: { + type: 'object', + properties: { + // Path parameters first + ...pathProperties, + // Query parameters next + ...queryProperties, + // Explicit inputs override auto-generated ones + ...spec.inputs, + // Inject alias credential inputs for alias-auth and both-auth tools + + ...((spec.auth === 'aliasAuth' || spec.auth === 'both') && { + alias_username: { + type: 'string', + description: + 'Alias email address for authentication (e.g. user@example.com). ' + + 'Required for alias-authenticated endpoints. ' + + 'Falls back to FORWARD_EMAIL_ALIAS_USER env var.', + }, + alias_password: { + type: 'string', + description: + 'Generated alias password for authentication. ' + + 'Required for alias-authenticated endpoints. ' + + 'Falls back to FORWARD_EMAIL_ALIAS_PASSWORD env var. ' + + 'Generate one with the generateAliasPassword tool.', + }, + }), + }, + // Path parameters are always required; merge with explicit requiredInputs + required: [...pathParameters, ...(spec.requiredInputs || [])], }, }, - }, - auth: spec.auth || 'apiKey', - async invoke(arguments_) { - let {path} = spec; - const pathParameters = path.match(/{(\w+)}/g) || []; - const queryArguments = {}; - const bodyArguments = {}; - - // Extract alias credentials from arguments (don't send them to the API) - const aliasUser = arguments_.alias_username || defaultAliasUser; - const aliasPass = arguments_.alias_password || defaultAliasPassword; - - for (const key in arguments_) { - if (!Object.hasOwn(arguments_, key)) continue; - // Skip credential fields - if (key === 'alias_username' || key === 'alias_password') continue; - - if (pathParameters.includes(`{${key}}`)) { - path = path.replace(`{${key}}`, arguments_[key]); - } else if (spec.query && spec.query.includes(key)) { - queryArguments[key] = arguments_[key]; - } else { - bodyArguments[key] = arguments_[key]; - } - } + auth: spec.auth || 'apiKey', + async invoke(arguments_) { + let {path} = spec; + const pathParameters = path.match(/{(\w+)}/g) || []; + const queryArguments = {}; + const bodyArguments = {}; - const config = {params: queryArguments}; - const hasBody = Object.keys(bodyArguments).length > 0; + // Extract alias credentials from arguments (don't send them to the API) + const aliasUser = arguments_.alias_username || defaultAliasUser; + const aliasPass = arguments_.alias_password || defaultAliasPassword; - // Choose the right client based on auth type - let client; - switch (spec.auth) { - case 'aliasAuth': { - client = createAliasClient(aliasUser, aliasPass); + for (const key in arguments_) { + if (!Object.hasOwn(arguments_, key)) continue; + // Skip credential fields + if (key === 'alias_username' || key === 'alias_password') continue; - break; + if (pathParameters.includes(`{${key}}`)) { + path = path.replace(`{${key}}`, arguments_[key]); + } else if (spec.query && spec.query.includes(key)) { + queryArguments[key] = arguments_[key]; + } else { + bodyArguments[key] = arguments_[key]; + } } - case 'both': { - // Use alias credentials if provided, otherwise fall back to API key - client = - aliasUser && aliasPass - ? createAliasClient(aliasUser, aliasPass) - : apiKeyClient; + const config = {params: queryArguments}; + const hasBody = Object.keys(bodyArguments).length > 0; - break; - } + // Choose the right client based on auth type + let client; + switch (spec.auth) { + case 'aliasAuth': { + client = createAliasClient(aliasUser, aliasPass); - case 'none': { - client = axios.create({baseURL}); + break; + } - break; - } + case 'both': { + // Use alias credentials if provided, otherwise fall back to API key + client = + aliasUser && aliasPass + ? createAliasClient(aliasUser, aliasPass) + : apiKeyClient; + + break; + } - default: { - client = apiKeyClient; + case 'none': { + client = axios.create({baseURL}); + + break; + } + + default: { + client = apiKeyClient; + } } - } - let response; - if (spec.method === 'get' || spec.method === 'delete') { - response = await client[spec.method](path, config); - } else { - response = await client[spec.method]( - path, - hasBody ? bodyArguments : undefined, - config, - ); - } + let response; + if (spec.method === 'get' || spec.method === 'delete') { + response = await client[spec.method](path, config); + } else { + response = await client[spec.method]( + path, + hasBody ? bodyArguments : undefined, + config, + ); + } - return response.data; - }, - }); + return response.data; + }, + }; + }; const tools = { // @@ -161,6 +256,24 @@ const getTools = (options = {}) => { method: 'put', path: '/v1/account', auth: 'both', + inputs: { + email: { + type: 'string', + description: 'Email address to update on the account', + }, + given_name: { + type: 'string', + description: 'First name', + }, + family_name: { + type: 'string', + description: 'Last name', + }, + avatar_url: { + type: 'string', + description: 'Link to avatar image (URL)', + }, + }, }), // @@ -322,6 +435,61 @@ const getTools = (options = {}) => { method: 'post', path: '/v1/domains', auth: 'apiKey', + inputs: { + domain: { + type: 'string', + description: + 'Fully qualified domain name or IP address (e.g. "example.com")', + }, + plan: { + type: 'string', + description: 'Plan type: "free", "enhanced_protection", or "team"', + }, + catchall: { + type: 'string', + description: + 'Create a default catch-all alias (email address or "true" for default)', + }, + has_adult_content_protection: { + type: 'boolean', + description: 'Enable Spam Scanner adult content protection', + }, + has_phishing_protection: { + type: 'boolean', + description: 'Enable Spam Scanner phishing protection', + }, + has_executable_protection: { + type: 'boolean', + description: 'Enable Spam Scanner executable protection', + }, + has_virus_protection: { + type: 'boolean', + description: 'Enable Spam Scanner virus protection', + }, + has_recipient_verification: { + type: 'boolean', + description: + 'Require alias recipients to click email verification link', + }, + ignore_mx_check: { + type: 'boolean', + description: 'Ignore MX record check on the domain', + }, + retention_days: { + type: 'number', + description: + 'Number of days to retain emails (integer between 0 and 30)', + }, + bounce_webhook: { + type: 'string', + description: 'Webhook URL for bounce notifications', + }, + max_quota_per_alias: { + type: 'string', + description: 'Maximum storage quota per alias (e.g. "1GB")', + }, + }, + requiredInputs: ['domain'], }), getDomain: createTool({ name: 'getDomain', @@ -336,6 +504,50 @@ const getTools = (options = {}) => { method: 'put', path: '/v1/domains/{domain_id}', auth: 'apiKey', + inputs: { + smtp_port: { + type: 'string', + description: 'Custom SMTP forwarding port number', + }, + has_adult_content_protection: { + type: 'boolean', + description: 'Enable Spam Scanner adult content protection', + }, + has_phishing_protection: { + type: 'boolean', + description: 'Enable Spam Scanner phishing protection', + }, + has_executable_protection: { + type: 'boolean', + description: 'Enable Spam Scanner executable protection', + }, + has_virus_protection: { + type: 'boolean', + description: 'Enable Spam Scanner virus protection', + }, + has_recipient_verification: { + type: 'boolean', + description: + 'Require alias recipients to click email verification link', + }, + ignore_mx_check: { + type: 'boolean', + description: 'Ignore MX record check on the domain', + }, + retention_days: { + type: 'number', + description: + 'Number of days to retain emails (integer between 0 and 30)', + }, + bounce_webhook: { + type: 'string', + description: 'Webhook URL for bounce notifications', + }, + max_quota_per_alias: { + type: 'string', + description: 'Maximum storage quota per alias (e.g. "1GB")', + }, + }, }), deleteDomain: createTool({ name: 'deleteDomain', @@ -364,6 +576,7 @@ const getTools = (options = {}) => { method: 'post', path: '/v1/domains/{domain_id}/test-s3-connection', auth: 'apiKey', + inputs: {}, }), // @@ -382,6 +595,17 @@ const getTools = (options = {}) => { method: 'post', path: '/v1/domains/{domain_id}/catch-all-passwords', auth: 'apiKey', + inputs: { + new_password: { + type: 'string', + description: + 'Custom password to set (leave empty for auto-generated password)', + }, + description: { + type: 'string', + description: 'Description for organizing this password', + }, + }, }), deleteCatchAllPassword: createTool({ name: 'deleteCatchAllPassword', @@ -407,6 +631,18 @@ const getTools = (options = {}) => { method: 'post', path: '/v1/domains/{domain_id}/invites', auth: 'apiKey', + inputs: { + email: { + type: 'string', + description: 'Email address of the user to invite', + }, + group: { + type: 'string', + description: + 'Group assignment for the invited user: "admin" or "user"', + }, + }, + requiredInputs: ['email', 'group'], }), removeDomainInvite: createTool({ name: 'removeDomainInvite', @@ -425,6 +661,13 @@ const getTools = (options = {}) => { method: 'put', path: '/v1/domains/{domain_id}/members/{member_id}', auth: 'apiKey', + inputs: { + group: { + type: 'string', + description: 'Group assignment: "admin" or "user"', + }, + }, + requiredInputs: ['group'], }), removeDomainMember: createTool({ name: 'removeDomainMember', @@ -451,6 +694,76 @@ const getTools = (options = {}) => { method: 'post', path: '/v1/domains/{domain_id}/aliases', auth: 'apiKey', + inputs: { + name: { + type: 'string', + description: + 'Alias name (the part before @). Random if not provided.', + }, + recipients: { + type: 'string', + description: + 'Comma or newline separated email addresses to forward to', + }, + description: { + type: 'string', + description: 'Alias description', + }, + labels: { + type: 'string', + description: 'Comma separated list of labels', + }, + has_recipient_verification: { + type: 'boolean', + description: 'Require recipients to click an email verification link', + }, + is_enabled: { + type: 'boolean', + description: 'Whether the alias is enabled for email routing', + }, + error_code_if_disabled: { + type: 'number', + description: + 'SMTP error code when alias is disabled: 250, 421, or 550', + }, + has_imap: { + type: 'boolean', + description: 'Enable or disable IMAP storage for the alias', + }, + has_pgp: { + type: 'boolean', + description: 'Enable OpenPGP encryption for IMAP/POP3 storage', + }, + public_key: { + type: 'string', + description: 'OpenPGP public key in ASCII Armor format', + }, + max_quota: { + type: 'string', + description: 'Maximum storage quota for this alias (e.g. "1GB")', + }, + vacation_responder_is_enabled: { + type: 'boolean', + description: 'Enable automatic vacation responder', + }, + vacation_responder_start_date: { + type: 'string', + description: + 'Vacation responder start date (MM/DD/YYYY or YYYY-MM-DD)', + }, + vacation_responder_end_date: { + type: 'string', + description: 'Vacation responder end date (MM/DD/YYYY or YYYY-MM-DD)', + }, + vacation_responder_subject: { + type: 'string', + description: 'Subject line for the vacation responder (plaintext)', + }, + vacation_responder_message: { + type: 'string', + description: 'Message body for the vacation responder (plaintext)', + }, + }, }), getAlias: createTool({ name: 'getAlias', @@ -465,6 +778,75 @@ const getTools = (options = {}) => { method: 'put', path: '/v1/domains/{domain_id}/aliases/{alias_id}', auth: 'apiKey', + inputs: { + name: { + type: 'string', + description: 'Alias name (the part before @)', + }, + recipients: { + type: 'string', + description: + 'Comma or newline separated email addresses to forward to', + }, + description: { + type: 'string', + description: 'Alias description', + }, + labels: { + type: 'string', + description: 'Comma separated list of labels', + }, + has_recipient_verification: { + type: 'boolean', + description: 'Require recipients to click an email verification link', + }, + is_enabled: { + type: 'boolean', + description: 'Whether the alias is enabled for email routing', + }, + error_code_if_disabled: { + type: 'number', + description: + 'SMTP error code when alias is disabled: 250, 421, or 550', + }, + has_imap: { + type: 'boolean', + description: 'Enable or disable IMAP storage for the alias', + }, + has_pgp: { + type: 'boolean', + description: 'Enable OpenPGP encryption for IMAP/POP3 storage', + }, + public_key: { + type: 'string', + description: 'OpenPGP public key in ASCII Armor format', + }, + max_quota: { + type: 'string', + description: 'Maximum storage quota for this alias (e.g. "1GB")', + }, + vacation_responder_is_enabled: { + type: 'boolean', + description: 'Enable automatic vacation responder', + }, + vacation_responder_start_date: { + type: 'string', + description: + 'Vacation responder start date (MM/DD/YYYY or YYYY-MM-DD)', + }, + vacation_responder_end_date: { + type: 'string', + description: 'Vacation responder end date (MM/DD/YYYY or YYYY-MM-DD)', + }, + vacation_responder_subject: { + type: 'string', + description: 'Subject line for the vacation responder (plaintext)', + }, + vacation_responder_message: { + type: 'string', + description: 'Message body for the vacation responder (plaintext)', + }, + }, }), deleteAlias: createTool({ name: 'deleteAlias', @@ -482,6 +864,28 @@ const getTools = (options = {}) => { method: 'post', path: '/v1/domains/{domain_id}/aliases/{alias_id}/generate-password', auth: 'apiKey', + inputs: { + new_password: { + type: 'string', + description: + 'Custom password to set (leave empty for auto-generated password)', + }, + password: { + type: 'string', + description: + 'Existing password to change without deleting IMAP storage', + }, + is_override: { + type: 'boolean', + description: + 'Override existing password and delete associated IMAP storage', + }, + emailed_instructions: { + type: 'string', + description: + 'Email address to send the password and setup instructions to', + }, + }, }), // @@ -603,6 +1007,74 @@ const getTools = (options = {}) => { method: 'post', path: '/v1/emails', auth: 'both', + inputs: { + from: { + type: 'string', + description: 'Sender email address', + }, + to: { + type: 'string', + description: 'Comma separated list of recipient email addresses', + }, + cc: { + type: 'string', + description: 'Comma separated list of CC recipient email addresses', + }, + bcc: { + type: 'string', + description: 'Comma separated list of BCC recipient email addresses', + }, + subject: { + type: 'string', + description: 'Email subject line', + }, + text: { + type: 'string', + description: 'Plaintext version of the email body', + }, + html: { + type: 'string', + description: 'HTML version of the email body', + }, + attachments: { + type: 'string', + description: + 'JSON array of attachment objects with filename, content, and encoding', + }, + sender: { + type: 'string', + description: 'Email address for the Sender header', + }, + replyTo: { + type: 'string', + description: 'Email address for the Reply-To header', + }, + inReplyTo: { + type: 'string', + description: 'Message-ID that this email is replying to', + }, + references: { + type: 'string', + description: + 'Space separated list of Message-IDs in the reference chain', + }, + priority: { + type: 'string', + description: 'Email priority: "high", "normal" (default), or "low"', + }, + messageId: { + type: 'string', + description: 'Custom Message-ID value for the email header', + }, + date: { + type: 'string', + description: 'Date value for the email Date header (ISO 8601)', + }, + raw: { + type: 'string', + description: 'Custom RFC822 formatted message to send as raw email', + }, + }, }), getEmailLimit: createTool({ name: 'getEmailLimit', @@ -757,6 +1229,14 @@ const getTools = (options = {}) => { method: 'post', path: '/v1/encrypt', auth: 'none', + inputs: { + input: { + type: 'string', + description: + 'Any valid Forward Email plaintext DNS TXT record value to encrypt', + }, + }, + requiredInputs: ['input'], }), }; diff --git a/test/index.test.js b/test/index.test.js index 65b98e4..e164ae8 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -428,6 +428,372 @@ test('MCP Server', async (t) => { }, ); + // + // Input schema verification + // + await t.test( + 'every tool has an inputSchema with type object and properties', + async () => { + const child = runCli(); + try { + await initializeServer(child); + const result = await listTools(child); + + for (const tool of result.tools) { + assert(tool.inputSchema, `${tool.name} missing inputSchema`); + assert.strictEqual( + tool.inputSchema.type, + 'object', + `${tool.name} inputSchema.type should be "object"`, + ); + assert( + tool.inputSchema.properties && + typeof tool.inputSchema.properties === 'object', + `${tool.name} missing inputSchema.properties`, + ); + } + } finally { + killChild(child); + } + }, + ); + + await t.test('every tool has a required array in inputSchema', async () => { + const child = runCli(); + try { + await initializeServer(child); + const result = await listTools(child); + + for (const tool of result.tools) { + assert( + Array.isArray(tool.inputSchema.required), + `${tool.name} missing or non-array inputSchema.required`, + ); + } + } finally { + killChild(child); + } + }); + + await t.test( + 'tools with path params have those params in required', + async () => { + const child = runCli(); + try { + await initializeServer(child); + const result = await listTools(child); + + // Tools with known path params and their expected required path params + const toolsWithPathParameters = { + getDomain: ['domain_id'], + updateDomain: ['domain_id'], + deleteDomain: ['domain_id'], + verifyDomainRecords: ['domain_id'], + verifySmtpRecords: ['domain_id'], + testS3Connection: ['domain_id'], + listCatchAllPasswords: ['domain_id'], + createCatchAllPassword: ['domain_id'], + deleteCatchAllPassword: ['domain_id', 'token_id'], + updateDomainMember: ['domain_id', 'member_id'], + removeDomainMember: ['domain_id', 'member_id'], + listAliases: ['domain_id'], + createAlias: ['domain_id'], + getAlias: ['domain_id', 'alias_id'], + updateAlias: ['domain_id', 'alias_id'], + deleteAlias: ['domain_id', 'alias_id'], + generateAliasPassword: ['domain_id', 'alias_id'], + listSieveScripts: ['domain_id', 'alias_id'], + createSieveScript: ['domain_id', 'alias_id'], + getSieveScript: ['domain_id', 'alias_id', 'script_id'], + updateSieveScript: ['domain_id', 'alias_id', 'script_id'], + deleteSieveScript: ['domain_id', 'alias_id', 'script_id'], + activateSieveScript: ['domain_id', 'alias_id', 'script_id'], + getContact: ['id'], + updateContact: ['id'], + deleteContact: ['id'], + getCalendar: ['id'], + updateCalendar: ['id'], + deleteCalendar: ['id'], + getCalendarEvent: ['id'], + updateCalendarEvent: ['id'], + deleteCalendarEvent: ['id'], + getEmail: ['id'], + deleteEmail: ['id'], + getMessage: ['id'], + updateMessage: ['id'], + deleteMessage: ['id'], + getFolder: ['id'], + updateFolder: ['id'], + deleteFolder: ['id'], + getSieveScriptAliasAuth: ['script_id'], + updateSieveScriptAliasAuth: ['script_id'], + deleteSieveScriptAliasAuth: ['script_id'], + activateSieveScriptAliasAuth: ['script_id'], + }; + + for (const [toolName, expectedParameters] of Object.entries( + toolsWithPathParameters, + )) { + const tool = result.tools.find((t) => t.name === toolName); + assert(tool, `Tool ${toolName} not found`); + + for (const parameter of expectedParameters) { + assert( + tool.inputSchema.properties[parameter], + `${toolName} missing property definition for path param "${parameter}"`, + ); + assert( + tool.inputSchema.required.includes(parameter), + `${toolName} should have "${parameter}" in required array`, + ); + } + } + } finally { + killChild(child); + } + }, + ); + + await t.test( + 'tools with query params have those params in properties', + async () => { + const child = runCli(); + try { + await initializeServer(child); + const result = await listTools(child); + + // Spot-check: tools that have query params should have them as properties + const toolsWithQueryParameters = { + downloadLogs: ['domain', 'q'], + listDomains: ['sort', 'page', 'limit'], + listAliases: ['sort', 'page', 'limit'], + listEmails: ['q', 'domain', 'sort', 'page', 'limit'], + listMessages: ['folder', 'subject', 'q'], + getMessage: ['eml', 'attachments'], + listFolders: ['subscribed'], + }; + + for (const [toolName, expectedParameters] of Object.entries( + toolsWithQueryParameters, + )) { + const tool = result.tools.find((t) => t.name === toolName); + assert(tool, `Tool ${toolName} not found`); + + for (const parameter of expectedParameters) { + assert( + tool.inputSchema.properties[parameter], + `${toolName} missing property definition for query param "${parameter}"`, + ); + } + } + } finally { + killChild(child); + } + }, + ); + + await t.test('every property has a type and description', async () => { + const child = runCli(); + try { + await initializeServer(child); + const result = await listTools(child); + + for (const tool of result.tools) { + for (const [propertyName, propertyDefinition] of Object.entries( + tool.inputSchema.properties, + )) { + assert( + propertyDefinition.type, + `${tool.name}.${propertyName} missing type`, + ); + assert( + propertyDefinition.description && + propertyDefinition.description.length > 0, + `${tool.name}.${propertyName} missing or empty description`, + ); + } + } + } finally { + killChild(child); + } + }); + + // + // Body parameter schema verification for POST/PUT tools + // + await t.test( + 'POST/PUT tools have body parameter properties in their schemas', + async () => { + const child = runCli(); + try { + await initializeServer(child); + const result = await listTools(child); + + // Map of tool name -> expected body parameter names (at minimum) + // Only includes tools whose body params are documented in the official API docs. + const expectedBodyParameters = { + // Account + updateAccount: ['given_name', 'family_name'], + + // Domains + createDomain: ['domain', 'plan'], + updateDomain: ['smtp_port', 'has_adult_content_protection'], + + // Catch-all passwords + createCatchAllPassword: ['new_password'], + + // Domain invites + createDomainInvite: ['email', 'group'], + + // Domain members + updateDomainMember: ['group'], + + // Aliases + createAlias: ['name', 'recipients', 'description'], + updateAlias: ['name', 'recipients', 'description'], + + // Alias password + generateAliasPassword: ['new_password'], + + // Emails (SMTP) + sendEmail: ['from', 'to', 'subject', 'text', 'html'], + + // Encrypt + encryptRecord: ['input'], + + // Activate sieve scripts (alias auth) — POST but no body expected + // activateSieveScript / activateSieveScriptAliasAuth — activation is path-only + + // Test S3 connection — POST but body params are domain-config level + testS3Connection: [], + }; + + for (const [toolName, expectedParameters] of Object.entries( + expectedBodyParameters, + )) { + const tool = result.tools.find((t) => t.name === toolName); + assert(tool, `Tool ${toolName} not found`); + + for (const parameter of expectedParameters) { + assert( + tool.inputSchema.properties[parameter], + `${toolName} missing body parameter "${parameter}" in inputSchema.properties`, + ); + // Body params should have type and description + assert( + tool.inputSchema.properties[parameter].type, + `${toolName}.${parameter} missing type`, + ); + assert( + tool.inputSchema.properties[parameter].description && + tool.inputSchema.properties[parameter].description.length > 0, + `${toolName}.${parameter} missing or empty description`, + ); + } + } + } finally { + killChild(child); + } + }, + ); + + await t.test( + 'tools with required body params include them in required array', + async () => { + const child = runCli(); + try { + await initializeServer(child); + const result = await listTools(child); + + // Tools where certain body params should be required + const toolsWithRequiredBodyParameters = { + createDomain: ['domain'], + createDomainInvite: ['email', 'group'], + updateDomainMember: ['group'], + encryptRecord: ['input'], + }; + + for (const [toolName, requiredParameters] of Object.entries( + toolsWithRequiredBodyParameters, + )) { + const tool = result.tools.find((t) => t.name === toolName); + assert(tool, `Tool ${toolName} not found`); + + for (const parameter of requiredParameters) { + assert( + tool.inputSchema.required.includes(parameter), + `${toolName} should have "${parameter}" in required array`, + ); + } + } + } finally { + killChild(child); + } + }, + ); + + await t.test( + 'sendEmail has comprehensive email composition properties', + async () => { + const child = runCli(); + try { + await initializeServer(child); + const result = await listTools(child); + + const tool = result.tools.find((t) => t.name === 'sendEmail'); + assert(tool, 'sendEmail tool not found'); + + const emailProperties = [ + 'from', + 'to', + 'cc', + 'bcc', + 'subject', + 'text', + 'html', + 'attachments', + 'replyTo', + 'inReplyTo', + 'references', + ]; + + for (const property of emailProperties) { + assert( + tool.inputSchema.properties[property], + `sendEmail missing "${property}" property`, + ); + } + } finally { + killChild(child); + } + }, + ); + + await t.test('createAlias has vacation responder properties', async () => { + const child = runCli(); + try { + await initializeServer(child); + const result = await listTools(child); + + const tool = result.tools.find((t) => t.name === 'createAlias'); + assert(tool, 'createAlias tool not found'); + + const vacationProperties = [ + 'vacation_responder_is_enabled', + 'vacation_responder_message', + ]; + + for (const property of vacationProperties) { + assert( + tool.inputSchema.properties[property], + `createAlias missing "${property}" property`, + ); + } + } finally { + killChild(child); + } + }); + // Encrypt endpoint doesn't require auth, so test for success await t.test('encryptRecord returns a result', async () => { const child = runCli({FORWARD_EMAIL_API_KEY: 'test-key'});