Skip to content

Commit 448e687

Browse files
author
Lasim
committed
feat(backend): add email notifications for MCP installation events
1 parent 679fcb5 commit 448e687

File tree

6 files changed

+316
-0
lines changed

6 files changed

+316
-0
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//- @description Email notification when an MCP server is installed to a team
2+
//- @variables userName, serverName, serverDescription, teamName, dashboardUrl
3+
extends layouts/base.pug
4+
5+
block content
6+
h1 MCP Server Installed
7+
8+
p Hi #{userName},
9+
10+
p
11+
| The MCP server
12+
strong #{serverName}
13+
| has been successfully installed to team
14+
strong #{teamName}
15+
| .
16+
17+
if serverDescription
18+
.server-info
19+
p
20+
strong About this server:
21+
p= serverDescription
22+
23+
p You can now use this MCP server in your AI coding assistant.
24+
25+
if dashboardUrl
26+
.text-center
27+
a.button(href=dashboardUrl) View in Dashboard
28+
29+
p.text-muted
30+
| Best regards,
31+
br
32+
| The DeployStack Team
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
//- @description Email notification when an MCP server is removed from a team
2+
//- @variables userName, serverName, serverDescription, teamName
3+
extends layouts/base.pug
4+
5+
block content
6+
h1 MCP Server Removed
7+
8+
p Hi #{userName},
9+
10+
p
11+
| The MCP server
12+
strong #{serverName}
13+
| has been removed from team
14+
strong #{teamName}
15+
| .
16+
17+
if serverDescription
18+
.server-info
19+
p
20+
strong Server that was removed:
21+
p= serverDescription
22+
23+
p This MCP server is no longer available for use in your team. If you need to use it again, a team administrator can reinstall it from the MCP catalog.
24+
25+
p.text-muted
26+
| Best regards,
27+
br
28+
| The DeployStack Team

services/backend/src/email/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,21 @@ export interface TestEmailVariables {
137137
supportEmail?: string;
138138
}
139139

140+
export interface McpInstallationCreatedEmailVariables {
141+
userName: string;
142+
serverName: string;
143+
serverDescription?: string;
144+
teamName: string;
145+
dashboardUrl?: string;
146+
}
147+
148+
export interface McpInstallationDeletedEmailVariables {
149+
userName: string;
150+
serverName: string;
151+
serverDescription?: string;
152+
teamName: string;
153+
}
154+
140155
// Template registry for type safety
141156
export interface TemplateVariableMap {
142157
welcome: WelcomeEmailVariables;
@@ -145,6 +160,8 @@ export interface TemplateVariableMap {
145160
'email-verification': EmailVerificationVariables;
146161
'password-changed': PasswordChangedEmailVariables;
147162
test: TestEmailVariables;
163+
'mcp-installation-created': McpInstallationCreatedEmailVariables;
164+
'mcp-installation-deleted': McpInstallationDeletedEmailVariables;
148165
}
149166

150167
export type TemplateNames = keyof TemplateVariableMap;

services/backend/src/routes/mcp/installations/create.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { requireTeamPermission } from '../../../middleware/roleMiddleware';
44
import { McpInstallationService } from '../../../services/mcpInstallationService';
55
import { McpUserConfigurationService } from '../../../services/mcpUserConfigurationService';
66
import { SatelliteCommandService } from '../../../services/satelliteCommandService';
7+
import { McpInstallationNotificationService } from '../../../services/mcpInstallationNotificationService';
78
import { getDb } from '../../../db';
89
import {
910
TEAM_ID_PARAM_SCHEMA,
@@ -203,6 +204,18 @@ export default async function createInstallationRoute(server: FastifyInstance) {
203204
// Don't fail installation creation if event emission fails
204205
}
205206

207+
// Queue email notifications to all team members
208+
try {
209+
const notificationService = new McpInstallationNotificationService(db, request.log);
210+
await notificationService.notifyInstallationCreated(
211+
installationData.server_id,
212+
teamId
213+
);
214+
} catch (notificationError) {
215+
request.log.error(notificationError, `Failed to queue installation notification emails for installation ${installation.id}:`);
216+
// Don't fail installation creation if notification fails
217+
}
218+
206219
const response: InstallationSuccessResponse = {
207220
success: true,
208221
data: formatInstallationResponse(installation)
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { eq } from 'drizzle-orm';
2+
import type { AnyDatabase } from '../db';
3+
import type { FastifyBaseLogger } from 'fastify';
4+
import { getSchema } from '../db';
5+
import { JobQueueService } from './jobQueueService';
6+
7+
/**
8+
* Service for sending email notifications when MCP servers are installed or removed
9+
*/
10+
export class McpInstallationNotificationService {
11+
private readonly db: AnyDatabase;
12+
private readonly logger: FastifyBaseLogger;
13+
private readonly jobQueueService: JobQueueService;
14+
15+
constructor(db: AnyDatabase, logger: FastifyBaseLogger) {
16+
this.db = db;
17+
this.logger = logger;
18+
this.jobQueueService = new JobQueueService(db, logger);
19+
}
20+
21+
/**
22+
* Send installation created notifications to all team members
23+
*/
24+
async notifyInstallationCreated(
25+
serverId: string,
26+
teamId: string
27+
): Promise<void> {
28+
try {
29+
const serverInfo = await this.getServerInfo(serverId);
30+
const teamInfo = await this.getTeamInfo(teamId);
31+
const teamMembers = await this.getTeamMembers(teamId);
32+
33+
if (!serverInfo || !teamInfo) {
34+
this.logger.warn({
35+
operation: 'mcp_installation_notification',
36+
serverId,
37+
teamId,
38+
serverFound: !!serverInfo,
39+
teamFound: !!teamInfo
40+
}, 'Cannot send notification: server or team not found');
41+
return;
42+
}
43+
44+
if (teamMembers.length === 0) {
45+
this.logger.warn({
46+
operation: 'mcp_installation_notification',
47+
teamId
48+
}, 'No team members found to notify');
49+
return;
50+
}
51+
52+
// Queue email for each team member
53+
for (const member of teamMembers) {
54+
await this.jobQueueService.createJob('send_email', {
55+
to: member.email,
56+
subject: `MCP Server Installed - ${serverInfo.name}`,
57+
template: 'mcp-installation-created',
58+
variables: {
59+
userName: member.username || member.email,
60+
serverName: serverInfo.name,
61+
serverDescription: serverInfo.description || '',
62+
teamName: teamInfo.name,
63+
dashboardUrl: 'https://cloud.deploystack.io/dashboard'
64+
}
65+
});
66+
}
67+
68+
this.logger.info({
69+
operation: 'mcp_installation_notification',
70+
event: 'created',
71+
serverName: serverInfo.name,
72+
teamName: teamInfo.name,
73+
recipientCount: teamMembers.length
74+
}, `Queued ${teamMembers.length} installation notification emails`);
75+
76+
} catch (error) {
77+
this.logger.error({
78+
operation: 'mcp_installation_notification',
79+
event: 'created',
80+
error: error instanceof Error ? error.message : String(error)
81+
}, 'Failed to queue installation notification emails');
82+
}
83+
}
84+
85+
/**
86+
* Send installation deleted notifications to all team members
87+
*/
88+
async notifyInstallationDeleted(
89+
serverName: string,
90+
serverDescription: string,
91+
teamId: string
92+
): Promise<void> {
93+
try {
94+
const teamInfo = await this.getTeamInfo(teamId);
95+
const teamMembers = await this.getTeamMembers(teamId);
96+
97+
if (!teamInfo) {
98+
this.logger.warn({
99+
operation: 'mcp_installation_notification',
100+
teamId
101+
}, 'Cannot send notification: team not found');
102+
return;
103+
}
104+
105+
if (teamMembers.length === 0) {
106+
this.logger.warn({
107+
operation: 'mcp_installation_notification',
108+
teamId
109+
}, 'No team members found to notify');
110+
return;
111+
}
112+
113+
// Queue email for each team member
114+
for (const member of teamMembers) {
115+
await this.jobQueueService.createJob('send_email', {
116+
to: member.email,
117+
subject: `MCP Server Removed - ${serverName}`,
118+
template: 'mcp-installation-deleted',
119+
variables: {
120+
userName: member.username || member.email,
121+
serverName: serverName,
122+
serverDescription: serverDescription || '',
123+
teamName: teamInfo.name
124+
}
125+
});
126+
}
127+
128+
this.logger.info({
129+
operation: 'mcp_installation_notification',
130+
event: 'deleted',
131+
serverName: serverName,
132+
teamName: teamInfo.name,
133+
recipientCount: teamMembers.length
134+
}, `Queued ${teamMembers.length} removal notification emails`);
135+
136+
} catch (error) {
137+
this.logger.error({
138+
operation: 'mcp_installation_notification',
139+
event: 'deleted',
140+
error: error instanceof Error ? error.message : String(error)
141+
}, 'Failed to queue removal notification emails');
142+
}
143+
}
144+
145+
/**
146+
* Get server information from database
147+
*/
148+
private async getServerInfo(
149+
serverId: string
150+
): Promise<{ name: string; description: string | null } | null> {
151+
const schema = getSchema();
152+
const result = await this.db
153+
.select({
154+
name: schema.mcpServers.name,
155+
description: schema.mcpServers.description
156+
})
157+
.from(schema.mcpServers)
158+
.where(eq(schema.mcpServers.id, serverId))
159+
.limit(1);
160+
161+
return result[0] || null;
162+
}
163+
164+
/**
165+
* Get team information from database
166+
*/
167+
private async getTeamInfo(
168+
teamId: string
169+
): Promise<{ name: string } | null> {
170+
const schema = getSchema();
171+
const result = await this.db
172+
.select({
173+
name: schema.teams.name
174+
})
175+
.from(schema.teams)
176+
.where(eq(schema.teams.id, teamId))
177+
.limit(1);
178+
179+
return result[0] || null;
180+
}
181+
182+
/**
183+
* Get all team members with their email addresses
184+
*/
185+
private async getTeamMembers(
186+
teamId: string
187+
): Promise<Array<{ email: string; username: string | null }>> {
188+
const schema = getSchema();
189+
const result = await this.db
190+
.select({
191+
email: schema.authUser.email,
192+
username: schema.authUser.username
193+
})
194+
.from(schema.teamMemberships)
195+
.innerJoin(
196+
schema.authUser,
197+
eq(schema.teamMemberships.user_id, schema.authUser.id)
198+
)
199+
.where(eq(schema.teamMemberships.team_id, teamId));
200+
201+
return result;
202+
}
203+
}

services/backend/src/workers/mcpServerCascadeDeletionWorker.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { EVENT_NAMES } from '../events';
77
import { McpInstallationService } from '../services/mcpInstallationService';
88
import { McpCatalogService } from '../services/mcpCatalogService';
99
import { SatelliteCommandService } from '../services/satelliteCommandService';
10+
import { McpInstallationNotificationService } from '../services/mcpInstallationNotificationService';
1011

1112
/**
1213
* Payload interface for MCP server cascade deletion jobs
@@ -40,6 +41,7 @@ export class McpServerCascadeDeletionWorker implements Worker {
4041
private installationService: McpInstallationService;
4142
private catalogService: McpCatalogService;
4243
private satelliteCommandService: SatelliteCommandService;
44+
private notificationService: McpInstallationNotificationService;
4345

4446
constructor(
4547
private readonly db: AnyDatabase,
@@ -49,6 +51,7 @@ export class McpServerCascadeDeletionWorker implements Worker {
4951
this.installationService = new McpInstallationService(this.db, this.logger);
5052
this.catalogService = new McpCatalogService(this.db, this.logger);
5153
this.satelliteCommandService = new SatelliteCommandService(this.db, this.logger);
54+
this.notificationService = new McpInstallationNotificationService(this.db, this.logger);
5255
}
5356

5457
async execute(payload: unknown, jobId: string): Promise<WorkerResult> {
@@ -106,6 +109,7 @@ export class McpServerCascadeDeletionWorker implements Worker {
106109
installation,
107110
serverId,
108111
serverName,
112+
serverDescription,
109113
deletedBy,
110114
metadata,
111115
jobId
@@ -203,6 +207,7 @@ export class McpServerCascadeDeletionWorker implements Worker {
203207
},
204208
serverId: string,
205209
serverName: string,
210+
serverDescription: string,
206211
deletedBy: { id: string; email: string },
207212
metadata: { ip: string },
208213
jobId: string
@@ -226,6 +231,24 @@ export class McpServerCascadeDeletionWorker implements Worker {
226231
metadata
227232
);
228233

234+
// Queue email notifications to all team members
235+
try {
236+
await this.notificationService.notifyInstallationDeleted(
237+
serverName,
238+
serverDescription,
239+
teamId
240+
);
241+
} catch (notificationError) {
242+
this.logger.warn({
243+
jobId,
244+
installationId,
245+
teamId,
246+
error: notificationError instanceof Error ? notificationError.message : String(notificationError),
247+
operation: 'mcp_server_cascade_delete_notification_error'
248+
}, 'Failed to queue notification emails, continuing with deletion');
249+
// Don't fail the installation deletion if notification fails
250+
}
251+
229252
// Notify satellites to refresh configuration
230253
try {
231254
const commands = await this.satelliteCommandService.notifyMcpInstallation(

0 commit comments

Comments
 (0)