diff --git a/package-lock.json b/package-lock.json index eda45a8..d6fbecc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "sendgrid-mcp-server", - "version": "0.1.0", + "name": "sendgrid-mcp", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "sendgrid-mcp-server", - "version": "0.1.0", + "name": "sendgrid-mcp", + "version": "1.0.0", "dependencies": { "@modelcontextprotocol/sdk": "0.6.0", "@sendgrid/client": "^8.1.4", @@ -14,7 +14,7 @@ "@sendgrid/mail": "^8.1.4" }, "bin": { - "sendgrid-mcp-server": "build/index.js" + "sendgrid-mcp": "build/index.js" }, "devDependencies": { "@types/jest": "^29.5.14", diff --git a/src/services/__tests__/sendgrid.test.ts b/src/services/__tests__/sendgrid.test.ts index 87d1b84..27676af 100644 --- a/src/services/__tests__/sendgrid.test.ts +++ b/src/services/__tests__/sendgrid.test.ts @@ -87,17 +87,165 @@ describe('SendGridService Integration Tests', () => { }); }); - describe('listTemplates', () => { - it('should return an array of templates', async () => { - const templates = await service.listTemplates(); - - expect(Array.isArray(templates)).toBe(true); - if (templates.length > 0) { - expect(templates[0]).toHaveProperty('id'); - expect(templates[0]).toHaveProperty('name'); - expect(templates[0]).toHaveProperty('generation'); + describe('Template Management', () => { + let testTemplateId: string; + let testVersionId: string; + + afterAll(async () => { + if (testTemplateId) { + try { + await service.deleteTemplate(testTemplateId); + } catch (error) { + console.error('Error cleaning up test template:', error); + } } }); + + it('should create a template', async () => { + const templateName = `Test Template ${new Date().getTime()}`; + + const template = await service.createTemplate({ + name: templateName, + generation: 'dynamic' + }); + + testTemplateId = template.id; + expect(template).toBeDefined(); + expect(template.id).toBeDefined(); + expect(template.name).toBe(templateName); + expect(template.generation).toBe('dynamic'); + }); + + it('should update a template name', async () => { + const newName = `Updated Template ${new Date().getTime()}`; + + const updatedTemplate = await service.updateTemplate(testTemplateId, { + name: newName + }); + + expect(updatedTemplate).toBeDefined(); + expect(updatedTemplate.name).toBe(newName); + }); + + it('should duplicate a template', async () => { + const duplicateName = `Duplicate Template ${new Date().getTime()}`; + + const duplicatedTemplate = await service.duplicateTemplate(testTemplateId, { + name: duplicateName + }); + + expect(duplicatedTemplate).toBeDefined(); + expect(duplicatedTemplate.id).toBeDefined(); + expect(duplicatedTemplate.id).not.toBe(testTemplateId); + expect(duplicatedTemplate.name).toBe(duplicateName); + + // Clean up duplicated template + await service.deleteTemplate(duplicatedTemplate.id); + }); + + it('should list templates with pagination', async () => { + const templateResponse = await service.listTemplates({ + generations: 'dynamic', + page_size: 10 + }); + + expect(templateResponse).toBeDefined(); + expect(Array.isArray(templateResponse.result)).toBe(true); + expect(templateResponse._metadata).toBeDefined(); + }); + + it('should create a template version', async () => { + const versionName = `Test Version ${new Date().getTime()}`; + + const version = await service.createTemplateVersion(testTemplateId, { + name: versionName, + subject: 'Test Subject {{name}}', + html_content: '

Hello {{name}}

', + plain_content: 'Hello {{name}}', + active: 1, + generate_plain_content: false + }); + + testVersionId = version.id; + expect(version).toBeDefined(); + expect(version.id).toBeDefined(); + expect(version.name).toBe(versionName); + expect(version.subject).toBe('Test Subject {{name}}'); + expect(version.active).toBe(1); + }); + + it('should get a template version', async () => { + const version = await service.getTemplateVersion(testTemplateId, testVersionId); + + expect(version).toBeDefined(); + expect(version.id).toBe(testVersionId); + expect(version.template_id).toBe(testTemplateId); + }); + + it('should update a template version', async () => { + const updatedSubject = 'Updated Subject {{name}}'; + + const updatedVersion = await service.updateTemplateVersion(testTemplateId, testVersionId, { + name: `Updated Version ${new Date().getTime()}`, + subject: updatedSubject, + html_content: '

Updated Hello {{name}}

', + plain_content: 'Updated Hello {{name}}' + }); + + expect(updatedVersion).toBeDefined(); + expect(updatedVersion.subject).toBe(updatedSubject); + }); + + it('should activate a template version', async () => { + // Create a second version + const newVersion = await service.createTemplateVersion(testTemplateId, { + name: `Version 2 ${new Date().getTime()}`, + subject: 'Version 2 Subject', + html_content: '

Version 2

', + plain_content: 'Version 2', + active: 0 + }); + + // Activate the new version + const activatedVersion = await service.activateTemplateVersion(testTemplateId, newVersion.id); + + expect(activatedVersion).toBeDefined(); + expect(activatedVersion.active).toBe(1); + + // Clean up + await service.deleteTemplateVersion(testTemplateId, newVersion.id); + }); + + it('should delete a template version', async () => { + // Create a version to delete + const versionToDelete = await service.createTemplateVersion(testTemplateId, { + name: `Delete Me ${new Date().getTime()}`, + subject: 'Delete Me', + html_content: '

Delete Me

', + plain_content: 'Delete Me' + }); + + // Delete the version + await service.deleteTemplateVersion(testTemplateId, versionToDelete.id); + + // Verify it's deleted by trying to get it + try { + await service.getTemplateVersion(testTemplateId, versionToDelete.id); + throw new Error('Version was not deleted'); + } catch (error: any) { + // Expect 404 error since version should be deleted + expect(error.code).toBe(404); + } + }); + + it('should get a template by ID', async () => { + const template = await service.getTemplate(testTemplateId); + + expect(template).toBeDefined(); + expect(template.id).toBe(testTemplateId); + expect(template.versions).toBeDefined(); + expect(Array.isArray(template.versions)).toBe(true); + }); }); describe('getStats', () => { diff --git a/src/services/sendgrid.ts b/src/services/sendgrid.ts index faa5841..f95198e 100644 --- a/src/services/sendgrid.ts +++ b/src/services/sendgrid.ts @@ -1,6 +1,6 @@ import { Client } from '@sendgrid/client'; import sgMail from '@sendgrid/mail'; -import { SendGridContact, SendGridList, SendGridTemplate, SendGridStats, SendGridSingleSend } from '../types/index.js'; +import { SendGridContact, SendGridList, SendGridTemplate, SendGridStats, SendGridSingleSend, SendGridListTemplatesResponse, SendGridTemplateVersionCreateParams, SendGridTemplateVersion } from '../types/index.js'; export class SendGridService { private client: Client; @@ -155,61 +155,67 @@ export class SendGridService { // Template Management async createTemplate(params: { name: string; - html_content: string; - plain_content: string; - subject: string; + generation?: 'legacy' | 'dynamic'; }): Promise { const [response] = await this.client.request({ method: 'POST', url: '/v3/templates', body: { name: params.name, - generation: 'dynamic' + generation: params.generation || 'dynamic' } }); - const templateId = (response.body as { id: string }).id; - - // Create the first version of the template - const [versionResponse] = await this.client.request({ - method: 'POST', - url: `/v3/templates/${templateId}/versions`, - body: { - template_id: templateId, - name: `${params.name} v1`, - subject: params.subject, - html_content: params.html_content, - plain_content: params.plain_content, - active: 1 - } + return response.body as SendGridTemplate; + } + + async updateTemplate(templateId: string, params: { + name: string; + }): Promise { + const [response] = await this.client.request({ + method: 'PATCH', + url: `/v3/templates/${templateId}`, + body: params }); + return response.body as SendGridTemplate; + } - return { - id: templateId, - name: params.name, - generation: 'dynamic', - updated_at: new Date().toISOString(), - versions: [{ - id: (versionResponse.body as { id: string }).id, - template_id: templateId, - active: 1, - name: `${params.name} v1`, - html_content: params.html_content, - plain_content: params.plain_content, - subject: params.subject - }] - }; + async duplicateTemplate(templateId: string, params: { + name?: string; + }): Promise { + const [response] = await this.client.request({ + method: 'POST', + url: `/v3/templates/${templateId}`, + body: params + }); + return response.body as SendGridTemplate; } - async listTemplates(): Promise { + async listTemplates(params?: { + generations?: 'legacy' | 'dynamic' | 'legacy,dynamic'; + page_size?: number; + page_token?: string; + }): Promise { const [response] = await this.client.request({ method: 'GET', url: '/v3/templates', qs: { - generations: 'dynamic' + generations: params?.generations || 'legacy', + page_size: params?.page_size, + page_token: params?.page_token } }); - return ((response.body as { templates: SendGridTemplate[] }).templates || []); + + // The SendGrid API returns templates directly as an array when using the v3/templates endpoint + // We need to wrap it in the expected structure + if (Array.isArray(response.body)) { + return { + result: response.body, + _metadata: response.headers['x-metadata'] ? JSON.parse(response.headers['x-metadata']) : undefined + }; + } + + return response.body as SendGridListTemplatesResponse; } async getTemplate(templateId: string): Promise { @@ -227,6 +233,48 @@ export class SendGridService { }); } + // Template Version Management + async createTemplateVersion(templateId: string, params: SendGridTemplateVersionCreateParams): Promise { + const [response] = await this.client.request({ + method: 'POST', + url: `/v3/templates/${templateId}/versions`, + body: params + }); + return response.body as SendGridTemplateVersion; + } + + async getTemplateVersion(templateId: string, versionId: string): Promise { + const [response] = await this.client.request({ + method: 'GET', + url: `/v3/templates/${templateId}/versions/${versionId}` + }); + return response.body as SendGridTemplateVersion; + } + + async updateTemplateVersion(templateId: string, versionId: string, params: SendGridTemplateVersionCreateParams): Promise { + const [response] = await this.client.request({ + method: 'PATCH', + url: `/v3/templates/${templateId}/versions/${versionId}`, + body: params + }); + return response.body as SendGridTemplateVersion; + } + + async deleteTemplateVersion(templateId: string, versionId: string): Promise { + await this.client.request({ + method: 'DELETE', + url: `/v3/templates/${templateId}/versions/${versionId}` + }); + } + + async activateTemplateVersion(templateId: string, versionId: string): Promise { + const [response] = await this.client.request({ + method: 'POST', + url: `/v3/templates/${templateId}/versions/${versionId}/activate` + }); + return response.body as SendGridTemplateVersion; + } + // Email Validation async validateEmail(email: string) { const [response] = await this.client.request({ diff --git a/src/tools/index.ts b/src/tools/index.ts index 511d707..513f5e9 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -137,20 +137,26 @@ export const getToolDefinitions = (service: SendGridService) => [ type: 'string', description: 'Name of the template' }, + generation: { + type: 'string', + enum: ['legacy', 'dynamic'], + description: 'Template generation type (defaults to dynamic)' + }, + // Legacy parameters for backward compatibility subject: { type: 'string', - description: 'Default subject line for the template' + description: '[DEPRECATED] Use create_template_version instead. Subject line for the template' }, html_content: { type: 'string', - description: 'HTML content of the template' + description: '[DEPRECATED] Use create_template_version instead. HTML content of the template' }, plain_content: { type: 'string', - description: 'Plain text content of the template' + description: '[DEPRECATED] Use create_template_version instead. Plain text content of the template' } }, - required: ['name', 'subject', 'html_content', 'plain_content'] + required: ['name'] } }, { @@ -223,10 +229,214 @@ export const getToolDefinitions = (service: SendGridService) => [ description: 'List all email templates in your SendGrid account', inputSchema: { type: 'object', - properties: {}, + properties: { + generations: { + type: 'string', + enum: ['legacy', 'dynamic', 'legacy,dynamic'], + description: 'Which generations of templates to return (defaults to legacy)' + }, + page_size: { + type: 'number', + description: 'Number of templates per page (1-200)' + }, + page_token: { + type: 'string', + description: 'Token for a specific page of results' + } + }, required: [] } }, + { + name: 'update_template', + description: 'Update the name of an existing template', + inputSchema: { + type: 'object', + properties: { + template_id: { + type: 'string', + description: 'ID of the template to update' + }, + name: { + type: 'string', + description: 'New name for the template' + } + }, + required: ['template_id', 'name'] + } + }, + { + name: 'duplicate_template', + description: 'Create a copy of an existing template', + inputSchema: { + type: 'object', + properties: { + template_id: { + type: 'string', + description: 'ID of the template to duplicate' + }, + name: { + type: 'string', + description: 'Name for the new template copy' + } + }, + required: ['template_id'] + } + }, + { + name: 'create_template_version', + description: 'Create a new version for a template', + inputSchema: { + type: 'object', + properties: { + template_id: { + type: 'string', + description: 'ID of the template' + }, + name: { + type: 'string', + description: 'Name of the version' + }, + subject: { + type: 'string', + description: 'Email subject line' + }, + html_content: { + type: 'string', + description: 'HTML content (max 1MB)' + }, + plain_content: { + type: 'string', + description: 'Plain text content (optional, generated from HTML if omitted)' + }, + generate_plain_content: { + type: 'boolean', + description: 'Auto-generate plain content from HTML (default: true)' + }, + active: { + type: 'number', + enum: [0, 1], + description: 'Set as active version (1) or inactive (0)' + }, + editor: { + type: 'string', + enum: ['code', 'design'], + description: 'Editor type used' + }, + test_data: { + type: 'string', + description: 'Mock JSON data for dynamic templates' + } + }, + required: ['template_id', 'name', 'subject', 'html_content'] + } + }, + { + name: 'get_template_version', + description: 'Get a specific version of a template', + inputSchema: { + type: 'object', + properties: { + template_id: { + type: 'string', + description: 'ID of the template' + }, + version_id: { + type: 'string', + description: 'ID of the version' + } + }, + required: ['template_id', 'version_id'] + } + }, + { + name: 'update_template_version', + description: 'Update an existing template version', + inputSchema: { + type: 'object', + properties: { + template_id: { + type: 'string', + description: 'ID of the template' + }, + version_id: { + type: 'string', + description: 'ID of the version to update' + }, + name: { + type: 'string', + description: 'Name of the version' + }, + subject: { + type: 'string', + description: 'Email subject line' + }, + html_content: { + type: 'string', + description: 'HTML content (max 1MB)' + }, + plain_content: { + type: 'string', + description: 'Plain text content' + }, + generate_plain_content: { + type: 'boolean', + description: 'Auto-generate plain content from HTML' + }, + active: { + type: 'number', + enum: [0, 1], + description: 'Set as active version (1) or inactive (0)' + }, + editor: { + type: 'string', + enum: ['code', 'design'], + description: 'Editor type used' + }, + test_data: { + type: 'string', + description: 'Mock JSON data for dynamic templates' + } + }, + required: ['template_id', 'version_id', 'name', 'subject', 'html_content'] + } + }, + { + name: 'delete_template_version', + description: 'Delete a specific template version', + inputSchema: { + type: 'object', + properties: { + template_id: { + type: 'string', + description: 'ID of the template' + }, + version_id: { + type: 'string', + description: 'ID of the version to delete' + } + }, + required: ['template_id', 'version_id'] + } + }, + { + name: 'activate_template_version', + description: 'Activate a specific template version', + inputSchema: { + type: 'object', + properties: { + template_id: { + type: 'string', + description: 'ID of the template' + }, + version_id: { + type: 'string', + description: 'ID of the version to activate' + } + }, + required: ['template_id', 'version_id'] + } + }, { name: 'delete_list', description: 'Delete a contact list from SendGrid', @@ -409,8 +619,37 @@ export const handleToolCall = async (service: SendGridService, name: string, arg return { content: [{ type: 'text', text: `Added ${args.emails.length} contacts to list ${args.list_id}` }] }; case 'create_template': - const template = await service.createTemplate(args); - return { content: [{ type: 'text', text: `Template "${args.name}" created with ID: ${template.id}` }] }; + // Check if using legacy parameters + if (args.subject && args.html_content && args.plain_content) { + // Legacy behavior: create template and first version + const template = await service.createTemplate({ + name: args.name, + generation: args.generation || 'dynamic' + }); + + try { + // Create the first version with the provided content + await service.createTemplateVersion(template.id, { + name: `${args.name} v1`, + subject: args.subject, + html_content: args.html_content, + plain_content: args.plain_content, + active: 1 + }); + + return { content: [{ type: 'text', text: `Template "${args.name}" created with ID: ${template.id} (legacy mode - created with initial version)` }] }; + } catch (error) { + // If version creation fails, still return the template ID + return { content: [{ type: 'text', text: `Template "${args.name}" created with ID: ${template.id} (warning: initial version creation failed)` }] }; + } + } else { + // New behavior: just create the template + const template = await service.createTemplate({ + name: args.name, + generation: args.generation + }); + return { content: [{ type: 'text', text: `Template "${args.name}" created with ID: ${template.id}` }] }; + } case 'get_template': const retrievedTemplate = await service.getTemplate(args.template_id); @@ -429,17 +668,24 @@ export const handleToolCall = async (service: SendGridService, name: string, arg return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] }; case 'list_templates': - const templates = await service.listTemplates(); + const templateResponse = await service.listTemplates(args); + + // Handle case where result might be undefined or response structure is different + const templates = templateResponse?.result || []; + return { content: [{ type: 'text', - text: JSON.stringify(templates.map(t => ({ - id: t.id, - name: t.name, - generation: t.generation, - updated_at: t.updated_at, - versions: t.versions.length - })), null, 2) + text: JSON.stringify({ + templates: Array.isArray(templates) ? templates.map(t => ({ + id: t.id, + name: t.name, + generation: t.generation, + updated_at: t.updated_at, + versions: t.versions ? t.versions.length : 0 + })) : [], + metadata: templateResponse?._metadata + }, null, 2) }] }; @@ -554,6 +800,36 @@ export const handleToolCall = async (service: SendGridService, name: string, arg await service.removeContactsFromList(args.list_id, args.emails); return { content: [{ type: 'text', text: `Removed ${args.emails.length} contacts from list ${args.list_id}` }] }; + case 'update_template': + const updatedTemplate = await service.updateTemplate(args.template_id, { name: args.name }); + return { content: [{ type: 'text', text: `Template ${args.template_id} renamed to "${args.name}"` }] }; + + case 'duplicate_template': + const duplicatedTemplate = await service.duplicateTemplate(args.template_id, args.name ? { name: args.name } : {}); + return { content: [{ type: 'text', text: `Template duplicated with new ID: ${duplicatedTemplate.id}` }] }; + + case 'create_template_version': + const { template_id, ...versionParams } = args; + const newVersion = await service.createTemplateVersion(template_id, versionParams); + return { content: [{ type: 'text', text: `Template version created with ID: ${newVersion.id}` }] }; + + case 'get_template_version': + const version = await service.getTemplateVersion(args.template_id, args.version_id); + return { content: [{ type: 'text', text: JSON.stringify(version, null, 2) }] }; + + case 'update_template_version': + const { template_id: tid, version_id, ...updateParams } = args; + const updatedVersion = await service.updateTemplateVersion(tid, version_id, updateParams); + return { content: [{ type: 'text', text: `Template version ${version_id} updated successfully` }] }; + + case 'delete_template_version': + await service.deleteTemplateVersion(args.template_id, args.version_id); + return { content: [{ type: 'text', text: `Template version ${args.version_id} deleted successfully` }] }; + + case 'activate_template_version': + const activatedVersion = await service.activateTemplateVersion(args.template_id, args.version_id); + return { content: [{ type: 'text', text: `Template version ${args.version_id} activated successfully` }] }; + default: throw new Error(`Unknown tool: ${name}`); } diff --git a/src/types/index.ts b/src/types/index.ts index b55cd77..e539acf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -17,9 +17,12 @@ export interface SendGridList { export interface SendGridTemplate { id: string; name: string; - generation: string; + generation: 'legacy' | 'dynamic'; updated_at: string; - versions: SendGridTemplateVersion[]; + versions?: SendGridTemplateVersion[]; + warning?: { + message: string; + }; } export interface SendGridTemplateVersion { @@ -30,6 +33,11 @@ export interface SendGridTemplateVersion { html_content: string; plain_content: string; subject: string; + generate_plain_content?: boolean; + editor?: 'code' | 'design'; + test_data?: string; + updated_at?: string; + thumbnail_url?: string; } export interface SendGridStats extends Array<{ @@ -74,3 +82,29 @@ export interface SendGridSingleSend { custom_unsubscribe_url?: string; }; } + +export interface SendGridListTemplatesResponse { + result: SendGridTemplate[]; + _metadata?: { + self?: string; + next?: string; + prev?: string; + count?: number; + }; +} + +export interface SendGridTemplateCreateParams { + name: string; + generation?: 'legacy' | 'dynamic'; +} + +export interface SendGridTemplateVersionCreateParams { + active?: 0 | 1; + name: string; + html_content: string; + plain_content?: string; + generate_plain_content?: boolean; + subject: string; + editor?: 'code' | 'design'; + test_data?: string; +}