@relayrail/server 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/README.md +74 -0
- package/dist/index.d.ts +507 -0
- package/dist/index.js +2213 -0
- package/package.json +77 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/server.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
// ../database/dist/index.js
|
|
9
|
+
import { createClient } from "@supabase/supabase-js";
|
|
10
|
+
function createServiceClient(url, serviceRoleKey) {
|
|
11
|
+
return createClient(url, serviceRoleKey, {
|
|
12
|
+
auth: {
|
|
13
|
+
autoRefreshToken: false,
|
|
14
|
+
persistSession: false
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ../shared/src/index.ts
|
|
20
|
+
var TIER_LIMITS = {
|
|
21
|
+
free: {
|
|
22
|
+
emailsPerMonth: 100,
|
|
23
|
+
smsPerMonth: 0,
|
|
24
|
+
maxAgents: 1,
|
|
25
|
+
historyDays: 7,
|
|
26
|
+
smsEnabled: false
|
|
27
|
+
},
|
|
28
|
+
pro: {
|
|
29
|
+
emailsPerMonth: 1e3,
|
|
30
|
+
smsPerMonth: 100,
|
|
31
|
+
maxAgents: 5,
|
|
32
|
+
historyDays: 30,
|
|
33
|
+
smsEnabled: true
|
|
34
|
+
},
|
|
35
|
+
team: {
|
|
36
|
+
emailsPerMonth: 1e4,
|
|
37
|
+
smsPerMonth: 1e3,
|
|
38
|
+
maxAgents: 25,
|
|
39
|
+
historyDays: 90,
|
|
40
|
+
smsEnabled: true
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
function getTierLimits(tier) {
|
|
44
|
+
return TIER_LIMITS[tier] || TIER_LIMITS.free;
|
|
45
|
+
}
|
|
46
|
+
var API_KEY_PREFIX = "rr_";
|
|
47
|
+
var REQUEST_ID_LENGTH = 8;
|
|
48
|
+
var DEFAULT_TIMEOUT_MINUTES = 60;
|
|
49
|
+
var MAX_TIMEOUT_MINUTES = 1440;
|
|
50
|
+
var SMS_OVERAGE_CENTS = 2;
|
|
51
|
+
var EMAIL_OVERAGE_CENTS = 1;
|
|
52
|
+
function sanitizeBaseUrl(url) {
|
|
53
|
+
return url.replace(/\/+$/, "");
|
|
54
|
+
}
|
|
55
|
+
function joinUrl(base, ...parts) {
|
|
56
|
+
const cleanBase = sanitizeBaseUrl(base);
|
|
57
|
+
const cleanParts = parts.map((p) => p.replace(/^\/+|\/+$/g, ""));
|
|
58
|
+
return [cleanBase, ...cleanParts].join("/");
|
|
59
|
+
}
|
|
60
|
+
function buildUrl(base, path, params) {
|
|
61
|
+
const url = joinUrl(base, path);
|
|
62
|
+
if (!params || Object.keys(params).length === 0) {
|
|
63
|
+
return url;
|
|
64
|
+
}
|
|
65
|
+
const queryString = Object.entries(params).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join("&");
|
|
66
|
+
return `${url}?${queryString}`;
|
|
67
|
+
}
|
|
68
|
+
function checkEmailQuota(tier, emailsSent, allowOverage, baseUrl) {
|
|
69
|
+
const limits = getTierLimits(tier);
|
|
70
|
+
const limit = limits.emailsPerMonth;
|
|
71
|
+
const remaining = Math.max(0, limit - emailsSent);
|
|
72
|
+
const withinIncluded = emailsSent < limit;
|
|
73
|
+
const isOverage = !withinIncluded;
|
|
74
|
+
const allowed = withinIncluded || isOverage && allowOverage;
|
|
75
|
+
const result = {
|
|
76
|
+
allowed,
|
|
77
|
+
withinIncluded,
|
|
78
|
+
isOverage,
|
|
79
|
+
current: emailsSent,
|
|
80
|
+
limit,
|
|
81
|
+
remaining,
|
|
82
|
+
tier
|
|
83
|
+
};
|
|
84
|
+
if (isOverage) {
|
|
85
|
+
result.overageRate = EMAIL_OVERAGE_CENTS / 100;
|
|
86
|
+
if (allowOverage) {
|
|
87
|
+
result.message = `Email sent as overage. You will be charged $${(EMAIL_OVERAGE_CENTS / 100).toFixed(2)} for this message.`;
|
|
88
|
+
} else {
|
|
89
|
+
result.message = `Email limit reached (${emailsSent}/${limit}). Overage charges are disabled in your settings.`;
|
|
90
|
+
const cleanBase = sanitizeBaseUrl(baseUrl);
|
|
91
|
+
result.enableOverageUrl = `${cleanBase}/settings?section=billing`;
|
|
92
|
+
result.upgradeUrl = `${cleanBase}/pricing?upgrade=true&reason=email_limit`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
function checkSmsQuota(tier, smsSent, allowOverage, baseUrl) {
|
|
98
|
+
const limits = getTierLimits(tier);
|
|
99
|
+
const limit = limits.smsPerMonth;
|
|
100
|
+
if (tier === "free" || !limits.smsEnabled) {
|
|
101
|
+
const cleanBase = sanitizeBaseUrl(baseUrl);
|
|
102
|
+
return {
|
|
103
|
+
allowed: false,
|
|
104
|
+
withinIncluded: false,
|
|
105
|
+
isOverage: false,
|
|
106
|
+
current: smsSent,
|
|
107
|
+
limit: 0,
|
|
108
|
+
remaining: 0,
|
|
109
|
+
tier,
|
|
110
|
+
message: "SMS is not available on the Free tier. Upgrade to Pro for 100 SMS/month.",
|
|
111
|
+
upgradeUrl: `${cleanBase}/pricing?upgrade=true&reason=sms_required`
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const remaining = Math.max(0, limit - smsSent);
|
|
115
|
+
const withinIncluded = smsSent < limit;
|
|
116
|
+
const isOverage = !withinIncluded;
|
|
117
|
+
const allowed = withinIncluded || isOverage && allowOverage;
|
|
118
|
+
const result = {
|
|
119
|
+
allowed,
|
|
120
|
+
withinIncluded,
|
|
121
|
+
isOverage,
|
|
122
|
+
current: smsSent,
|
|
123
|
+
limit,
|
|
124
|
+
remaining,
|
|
125
|
+
tier
|
|
126
|
+
};
|
|
127
|
+
if (isOverage) {
|
|
128
|
+
result.overageRate = SMS_OVERAGE_CENTS / 100;
|
|
129
|
+
if (allowOverage) {
|
|
130
|
+
result.message = `SMS sent as overage. You will be charged $${(SMS_OVERAGE_CENTS / 100).toFixed(2)} for this message.`;
|
|
131
|
+
} else {
|
|
132
|
+
result.message = `SMS limit reached (${smsSent}/${limit}). Overage charges are disabled in your settings.`;
|
|
133
|
+
const cleanBase = sanitizeBaseUrl(baseUrl);
|
|
134
|
+
result.enableOverageUrl = `${cleanBase}/settings?section=billing`;
|
|
135
|
+
result.upgradeUrl = `${cleanBase}/pricing?upgrade=true&reason=sms_limit`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/auth.ts
|
|
142
|
+
import { createHash } from "crypto";
|
|
143
|
+
function hashApiKey(apiKey) {
|
|
144
|
+
return createHash("sha256").update(apiKey).digest("hex");
|
|
145
|
+
}
|
|
146
|
+
function getApiKeyPrefix(apiKey) {
|
|
147
|
+
if (!apiKey.startsWith(API_KEY_PREFIX)) {
|
|
148
|
+
return "";
|
|
149
|
+
}
|
|
150
|
+
return apiKey.substring(0, API_KEY_PREFIX.length + 8);
|
|
151
|
+
}
|
|
152
|
+
async function authenticateApiKey(supabase, apiKey) {
|
|
153
|
+
if (!apiKey || !apiKey.startsWith(API_KEY_PREFIX)) {
|
|
154
|
+
return {
|
|
155
|
+
success: false,
|
|
156
|
+
error: "Invalid API key format",
|
|
157
|
+
authError: {
|
|
158
|
+
code: "INVALID_FORMAT",
|
|
159
|
+
message: "Invalid API key format",
|
|
160
|
+
hint: `API keys must start with "${API_KEY_PREFIX}". Use the register_agent tool to get a valid API key, or check your Claude Desktop configuration.`
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const expectedMinLength = 35;
|
|
165
|
+
if (apiKey.length < expectedMinLength) {
|
|
166
|
+
return {
|
|
167
|
+
success: false,
|
|
168
|
+
error: "API key appears truncated",
|
|
169
|
+
authError: {
|
|
170
|
+
code: "KEY_TRUNCATED",
|
|
171
|
+
message: "API key appears truncated",
|
|
172
|
+
hint: `Expected at least ${expectedMinLength} characters, but received ${apiKey.length}. Make sure you copied the complete API key. It should look like: rr_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX`
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const prefix = getApiKeyPrefix(apiKey);
|
|
177
|
+
const hash = hashApiKey(apiKey);
|
|
178
|
+
const agentQuery = supabase.from("agents").select("*").eq("api_key_prefix", prefix);
|
|
179
|
+
const agentResult = await agentQuery;
|
|
180
|
+
if (agentResult.error || !agentResult.data || agentResult.data.length === 0) {
|
|
181
|
+
return {
|
|
182
|
+
success: false,
|
|
183
|
+
error: "API key not found",
|
|
184
|
+
authError: {
|
|
185
|
+
code: "KEY_NOT_FOUND",
|
|
186
|
+
message: "API key not found",
|
|
187
|
+
hint: "This API key does not exist in our system. It may have been deleted, or was never created. Use the register_agent tool to create a new API key."
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const agent = agentResult.data.find((a) => a.api_key_hash === hash);
|
|
192
|
+
if (!agent) {
|
|
193
|
+
return {
|
|
194
|
+
success: false,
|
|
195
|
+
error: "API key verification failed",
|
|
196
|
+
authError: {
|
|
197
|
+
code: "HASH_MISMATCH",
|
|
198
|
+
message: "API key verification failed",
|
|
199
|
+
hint: 'The API key prefix was recognized, but the full key verification failed. This usually means the key was corrupted or truncated. Please check that you copied the complete API key, including all characters after "rr_".'
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (!agent.is_active) {
|
|
204
|
+
return {
|
|
205
|
+
success: false,
|
|
206
|
+
error: "API key is deactivated",
|
|
207
|
+
authError: {
|
|
208
|
+
code: "KEY_INACTIVE",
|
|
209
|
+
message: "API key is deactivated",
|
|
210
|
+
hint: "This API key has been deactivated. Visit your RelayRail dashboard to reactivate this agent or create a new one."
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const userQuery = supabase.from("users").select("*").eq("id", agent.user_id);
|
|
215
|
+
const userResult = await userQuery;
|
|
216
|
+
if (userResult.error || !userResult.data || userResult.data.length === 0) {
|
|
217
|
+
return {
|
|
218
|
+
success: false,
|
|
219
|
+
error: "User account not found",
|
|
220
|
+
authError: {
|
|
221
|
+
code: "USER_NOT_FOUND",
|
|
222
|
+
message: "User account not found",
|
|
223
|
+
hint: "The API key is valid, but the associated user account was not found. This may indicate a data synchronization issue. Please contact support or try creating a new account."
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const user = userResult.data[0];
|
|
228
|
+
const updateData = { last_seen_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
229
|
+
const updateQuery = supabase.from("agents").update(updateData).eq("id", agent.id);
|
|
230
|
+
await updateQuery;
|
|
231
|
+
return {
|
|
232
|
+
success: true,
|
|
233
|
+
agent,
|
|
234
|
+
user
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function generateApiKey() {
|
|
238
|
+
const randomBytes = new Uint8Array(24);
|
|
239
|
+
crypto.getRandomValues(randomBytes);
|
|
240
|
+
const keyPortion = Buffer.from(randomBytes).toString("base64url");
|
|
241
|
+
const key = `${API_KEY_PREFIX}${keyPortion}`;
|
|
242
|
+
const prefix = getApiKeyPrefix(key);
|
|
243
|
+
const hash = hashApiKey(key);
|
|
244
|
+
return { key, prefix, hash };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// src/tools/request-id.ts
|
|
248
|
+
import { nanoid } from "nanoid";
|
|
249
|
+
function generateRequestId() {
|
|
250
|
+
return nanoid(REQUEST_ID_LENGTH);
|
|
251
|
+
}
|
|
252
|
+
function generateResponseToken() {
|
|
253
|
+
return nanoid(32);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/tools/request-approval.ts
|
|
257
|
+
function createUserContext(user) {
|
|
258
|
+
return {
|
|
259
|
+
email: user.email,
|
|
260
|
+
tier: user.tier,
|
|
261
|
+
preferred_channel: user.notification_preferences?.preferred_channel || "email"
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
async function requestApproval(params, context) {
|
|
265
|
+
const { supabase, agent, user, router } = context;
|
|
266
|
+
const {
|
|
267
|
+
message,
|
|
268
|
+
options,
|
|
269
|
+
context: requestContext,
|
|
270
|
+
timeout_minutes = DEFAULT_TIMEOUT_MINUTES,
|
|
271
|
+
severity = "info"
|
|
272
|
+
} = params;
|
|
273
|
+
const requestId = generateRequestId();
|
|
274
|
+
const responseToken = generateResponseToken();
|
|
275
|
+
const expiresAt = new Date(Date.now() + timeout_minutes * 60 * 1e3);
|
|
276
|
+
const requestData = {
|
|
277
|
+
id: requestId,
|
|
278
|
+
agent_id: agent.id,
|
|
279
|
+
type: "approval",
|
|
280
|
+
status: "pending",
|
|
281
|
+
payload: {
|
|
282
|
+
message,
|
|
283
|
+
options,
|
|
284
|
+
context: requestContext,
|
|
285
|
+
timeout_minutes,
|
|
286
|
+
severity
|
|
287
|
+
},
|
|
288
|
+
response_token: responseToken,
|
|
289
|
+
expires_at: expiresAt.toISOString()
|
|
290
|
+
};
|
|
291
|
+
const insertResult = await supabase.from("requests").insert(requestData);
|
|
292
|
+
if (insertResult.error) {
|
|
293
|
+
throw new Error(`Failed to create request: ${insertResult.error.message}`);
|
|
294
|
+
}
|
|
295
|
+
if (router) {
|
|
296
|
+
const routeResult = await router.routeApproval(
|
|
297
|
+
{
|
|
298
|
+
requestId,
|
|
299
|
+
responseToken,
|
|
300
|
+
message,
|
|
301
|
+
options,
|
|
302
|
+
expiresAt: expiresAt.toISOString(),
|
|
303
|
+
severity
|
|
304
|
+
},
|
|
305
|
+
user,
|
|
306
|
+
agent
|
|
307
|
+
);
|
|
308
|
+
if (routeResult.quotaError) {
|
|
309
|
+
await supabase.from("requests").update({ status: "expired" }).eq("id", requestId);
|
|
310
|
+
return {
|
|
311
|
+
request_id: requestId,
|
|
312
|
+
status: "blocked",
|
|
313
|
+
expires_at: expiresAt.toISOString(),
|
|
314
|
+
user: createUserContext(user),
|
|
315
|
+
quota_error: routeResult.quotaError
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
if (!routeResult.success) {
|
|
319
|
+
console.warn(`[RelayRail] Failed to send approval notification:`, routeResult.error);
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
request_id: requestId,
|
|
323
|
+
status: "pending",
|
|
324
|
+
expires_at: expiresAt.toISOString(),
|
|
325
|
+
user: createUserContext(user),
|
|
326
|
+
quota: routeResult.quota
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
console.log(`[RelayRail] Approval request created:`, {
|
|
330
|
+
requestId,
|
|
331
|
+
user: user.email,
|
|
332
|
+
agent: agent.name
|
|
333
|
+
});
|
|
334
|
+
return {
|
|
335
|
+
request_id: requestId,
|
|
336
|
+
status: "pending",
|
|
337
|
+
expires_at: expiresAt.toISOString(),
|
|
338
|
+
user: createUserContext(user)
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/tools/send-notification.ts
|
|
343
|
+
function createUserContext2(user) {
|
|
344
|
+
return {
|
|
345
|
+
email: user.email,
|
|
346
|
+
tier: user.tier,
|
|
347
|
+
preferred_channel: user.notification_preferences?.preferred_channel || "email"
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
async function sendNotification(params, context) {
|
|
351
|
+
const { supabase, agent, user, router } = context;
|
|
352
|
+
const {
|
|
353
|
+
message,
|
|
354
|
+
context: notificationContext,
|
|
355
|
+
severity = "info"
|
|
356
|
+
} = params;
|
|
357
|
+
const requestId = generateRequestId();
|
|
358
|
+
const preferredChannel = user.notification_preferences?.preferred_channel || "email";
|
|
359
|
+
const requestData = {
|
|
360
|
+
id: requestId,
|
|
361
|
+
agent_id: agent.id,
|
|
362
|
+
type: "notification",
|
|
363
|
+
status: "pending",
|
|
364
|
+
payload: {
|
|
365
|
+
message,
|
|
366
|
+
context: notificationContext,
|
|
367
|
+
severity
|
|
368
|
+
},
|
|
369
|
+
// No response token needed for notifications
|
|
370
|
+
response_token: null,
|
|
371
|
+
expires_at: null
|
|
372
|
+
// Notifications don't expire
|
|
373
|
+
};
|
|
374
|
+
const insertResult = await supabase.from("requests").insert(requestData);
|
|
375
|
+
if (insertResult.error) {
|
|
376
|
+
throw new Error(`Failed to create notification: ${insertResult.error.message}`);
|
|
377
|
+
}
|
|
378
|
+
let channel = preferredChannel;
|
|
379
|
+
if (router) {
|
|
380
|
+
const routeResult = await router.routeNotification(
|
|
381
|
+
{
|
|
382
|
+
requestId,
|
|
383
|
+
message,
|
|
384
|
+
severity
|
|
385
|
+
},
|
|
386
|
+
user,
|
|
387
|
+
agent
|
|
388
|
+
);
|
|
389
|
+
channel = routeResult.channel;
|
|
390
|
+
if (routeResult.quotaError) {
|
|
391
|
+
await supabase.from("requests").update({ status: "expired" }).eq("id", requestId);
|
|
392
|
+
return {
|
|
393
|
+
request_id: requestId,
|
|
394
|
+
status: "blocked",
|
|
395
|
+
channel,
|
|
396
|
+
user: createUserContext2(user),
|
|
397
|
+
quota_error: routeResult.quotaError
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (!routeResult.success) {
|
|
401
|
+
console.warn(`[RelayRail] Failed to send notification:`, routeResult.error);
|
|
402
|
+
return {
|
|
403
|
+
request_id: requestId,
|
|
404
|
+
status: "failed",
|
|
405
|
+
channel,
|
|
406
|
+
user: createUserContext2(user)
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
request_id: requestId,
|
|
411
|
+
status: "pending",
|
|
412
|
+
channel,
|
|
413
|
+
user: createUserContext2(user),
|
|
414
|
+
quota: routeResult.quota
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
console.log(`[RelayRail] Notification sent:`, {
|
|
418
|
+
requestId,
|
|
419
|
+
channel,
|
|
420
|
+
user: user.email,
|
|
421
|
+
agent: agent.name,
|
|
422
|
+
severity
|
|
423
|
+
});
|
|
424
|
+
return {
|
|
425
|
+
request_id: requestId,
|
|
426
|
+
status: "pending",
|
|
427
|
+
channel,
|
|
428
|
+
user: createUserContext2(user)
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// src/tools/await-response.ts
|
|
433
|
+
var DEFAULT_TIMEOUT_SECONDS = 30;
|
|
434
|
+
var POLL_INTERVAL_MS = 1e3;
|
|
435
|
+
async function awaitResponse(params, context) {
|
|
436
|
+
const { supabase, agent } = context;
|
|
437
|
+
const {
|
|
438
|
+
request_id,
|
|
439
|
+
timeout_seconds = DEFAULT_TIMEOUT_SECONDS
|
|
440
|
+
} = params;
|
|
441
|
+
const startTime = Date.now();
|
|
442
|
+
const timeoutMs = timeout_seconds * 1e3;
|
|
443
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
444
|
+
const requestResult = await supabase.from("requests").select("*").eq("id", request_id).eq("agent_id", agent.id);
|
|
445
|
+
const result = requestResult;
|
|
446
|
+
if (result.error || !result.data || result.data.length === 0) {
|
|
447
|
+
return {
|
|
448
|
+
request_id,
|
|
449
|
+
status: "pending",
|
|
450
|
+
timed_out: false
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
const request = result.data[0];
|
|
454
|
+
if (request.status !== "pending") {
|
|
455
|
+
return {
|
|
456
|
+
request_id,
|
|
457
|
+
status: request.status,
|
|
458
|
+
response: request.response,
|
|
459
|
+
timed_out: false
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
if (request.expires_at && new Date(request.expires_at) < /* @__PURE__ */ new Date()) {
|
|
463
|
+
await supabase.from("requests").update({ status: "expired" }).eq("id", request_id);
|
|
464
|
+
return {
|
|
465
|
+
request_id,
|
|
466
|
+
status: "expired",
|
|
467
|
+
timed_out: false
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
request_id,
|
|
474
|
+
status: "pending",
|
|
475
|
+
timed_out: true
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// src/tools/get-pending-commands.ts
|
|
480
|
+
var DEFAULT_LIMIT = 10;
|
|
481
|
+
async function getPendingCommands(params, context) {
|
|
482
|
+
const { supabase, agent } = context;
|
|
483
|
+
const { limit = DEFAULT_LIMIT } = params;
|
|
484
|
+
const requestResult = await supabase.from("requests").select("*, messages(*)").eq("agent_id", agent.id).eq("type", "command").eq("status", "pending").order("created_at", { ascending: false }).limit(limit);
|
|
485
|
+
const result = requestResult;
|
|
486
|
+
if (result.error || !result.data) {
|
|
487
|
+
return { commands: [] };
|
|
488
|
+
}
|
|
489
|
+
const commands = result.data.map((request) => ({
|
|
490
|
+
request_id: request.id,
|
|
491
|
+
message: request.payload.message,
|
|
492
|
+
received_at: request.created_at,
|
|
493
|
+
channel: request.messages?.[0]?.channel || "email"
|
|
494
|
+
}));
|
|
495
|
+
if (commands.length > 0) {
|
|
496
|
+
const commandIds = commands.map((c) => c.request_id);
|
|
497
|
+
await supabase.from("requests").update({ status: "delivered" }).in("id", commandIds);
|
|
498
|
+
}
|
|
499
|
+
return { commands };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// src/tools/register-agent.ts
|
|
503
|
+
async function registerAgent(params, context) {
|
|
504
|
+
const { supabase, baseUrl } = context;
|
|
505
|
+
const { name, user_email, auto_create = true } = params;
|
|
506
|
+
const cleanBaseUrl = sanitizeBaseUrl(baseUrl);
|
|
507
|
+
if (!name || name.trim().length === 0) {
|
|
508
|
+
return {
|
|
509
|
+
success: false,
|
|
510
|
+
error: "Agent name is required"
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if (!user_email || !user_email.includes("@")) {
|
|
514
|
+
return {
|
|
515
|
+
success: false,
|
|
516
|
+
error: "Valid user email is required"
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
const normalizedEmail = user_email.toLowerCase().trim();
|
|
520
|
+
const userQuery = supabase.from("users").select("*").eq("email", normalizedEmail);
|
|
521
|
+
const userResult = await userQuery;
|
|
522
|
+
if (userResult.error) {
|
|
523
|
+
return {
|
|
524
|
+
success: false,
|
|
525
|
+
error: `Database error: ${userResult.error.message}`
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
let user;
|
|
529
|
+
let accountCreated = false;
|
|
530
|
+
if (!userResult.data || userResult.data.length === 0) {
|
|
531
|
+
if (!auto_create) {
|
|
532
|
+
const signupUrl = buildUrl(cleanBaseUrl, "signup", { email: normalizedEmail });
|
|
533
|
+
const docsUrl = buildUrl(cleanBaseUrl, "docs/troubleshooting");
|
|
534
|
+
return {
|
|
535
|
+
success: false,
|
|
536
|
+
error: `No account found for "${normalizedEmail}".`,
|
|
537
|
+
signup_url: signupUrl,
|
|
538
|
+
instructions: `To use RelayRail, you need an account:
|
|
539
|
+
|
|
540
|
+
1. SIGN UP: Visit ${signupUrl}
|
|
541
|
+
2. VERIFY: Check your email and click the verification link
|
|
542
|
+
3. RETURN: Run register_agent again with the same email
|
|
543
|
+
|
|
544
|
+
If you already signed up:
|
|
545
|
+
- Check for typos in the email address
|
|
546
|
+
- Make sure you completed email verification
|
|
547
|
+
- Allow up to 60 seconds for account creation to complete
|
|
548
|
+
|
|
549
|
+
Need help? Visit ${docsUrl}`
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
const newUserId = crypto.randomUUID();
|
|
553
|
+
const createUserResult = await supabase.from("users").insert({
|
|
554
|
+
id: newUserId,
|
|
555
|
+
email: normalizedEmail,
|
|
556
|
+
tier: "free",
|
|
557
|
+
notification_preferences: { preferred_channel: "email" },
|
|
558
|
+
email_verified: false
|
|
559
|
+
}).select("*").single();
|
|
560
|
+
if (createUserResult.error) {
|
|
561
|
+
if (createUserResult.error.message.includes("duplicate") || createUserResult.error.message.includes("unique")) {
|
|
562
|
+
const retryQuery = supabase.from("users").select("*").eq("email", normalizedEmail).single();
|
|
563
|
+
const retryResult = await retryQuery;
|
|
564
|
+
if (retryResult.error || !retryResult.data) {
|
|
565
|
+
return {
|
|
566
|
+
success: false,
|
|
567
|
+
error: `Failed to create or find account: ${createUserResult.error.message}`
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
user = retryResult.data;
|
|
571
|
+
} else {
|
|
572
|
+
return {
|
|
573
|
+
success: false,
|
|
574
|
+
error: `Failed to create account: ${createUserResult.error.message}`
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
} else {
|
|
578
|
+
user = createUserResult.data;
|
|
579
|
+
accountCreated = true;
|
|
580
|
+
}
|
|
581
|
+
} else {
|
|
582
|
+
user = userResult.data[0];
|
|
583
|
+
}
|
|
584
|
+
const { key, prefix, hash } = generateApiKey();
|
|
585
|
+
const agentData = {
|
|
586
|
+
user_id: user.id,
|
|
587
|
+
name: name.trim(),
|
|
588
|
+
api_key_hash: hash,
|
|
589
|
+
api_key_prefix: prefix,
|
|
590
|
+
is_active: true,
|
|
591
|
+
metadata: {
|
|
592
|
+
registered_via: "mcp_tool",
|
|
593
|
+
registered_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
594
|
+
auto_created_account: accountCreated
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
const insertResult = await supabase.from("agents").insert(agentData).select("id").single();
|
|
598
|
+
if (insertResult.error) {
|
|
599
|
+
return {
|
|
600
|
+
success: false,
|
|
601
|
+
error: `Failed to create agent: ${insertResult.error.message}`
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
const agentId = insertResult.data.id;
|
|
605
|
+
const usageResult = await supabase.rpc("get_or_create_usage", {
|
|
606
|
+
p_user_id: user.id
|
|
607
|
+
});
|
|
608
|
+
const usage = usageResult.data;
|
|
609
|
+
const tier = user.tier;
|
|
610
|
+
const limits = getTierLimits(tier);
|
|
611
|
+
const emailsUsed = usage?.emails_sent ?? 0;
|
|
612
|
+
const smsUsed = usage?.sms_sent ?? 0;
|
|
613
|
+
const channelsAvailable = ["email"];
|
|
614
|
+
if (tier !== "free" && limits.smsEnabled && user.phone) {
|
|
615
|
+
channelsAvailable.push("sms");
|
|
616
|
+
}
|
|
617
|
+
const accountStatus = {
|
|
618
|
+
email: user.email,
|
|
619
|
+
tier,
|
|
620
|
+
email_verified: user.email_verified ?? false,
|
|
621
|
+
channels_available: channelsAvailable,
|
|
622
|
+
quota: {
|
|
623
|
+
email: {
|
|
624
|
+
used: emailsUsed,
|
|
625
|
+
limit: limits.emailsPerMonth,
|
|
626
|
+
remaining: Math.max(0, limits.emailsPerMonth - emailsUsed)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
if (tier !== "free") {
|
|
631
|
+
accountStatus.quota.sms = {
|
|
632
|
+
used: smsUsed,
|
|
633
|
+
limit: limits.smsPerMonth,
|
|
634
|
+
remaining: Math.max(0, limits.smsPerMonth - smsUsed)
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
const nextSteps = [];
|
|
638
|
+
if (accountCreated) {
|
|
639
|
+
nextSteps.push(`New account created for ${normalizedEmail} on Free tier.`);
|
|
640
|
+
}
|
|
641
|
+
nextSteps.push(`Save your API key: ${key}`);
|
|
642
|
+
nextSteps.push(`Try: send_notification({message: "Hello from RelayRail!"})`);
|
|
643
|
+
if (tier === "free") {
|
|
644
|
+
nextSteps.push(`Upgrade to Pro for SMS and 1000 emails/month: ${cleanBaseUrl}/pricing`);
|
|
645
|
+
}
|
|
646
|
+
if (!user.email_verified) {
|
|
647
|
+
nextSteps.push(`Verify your email for full features: Check inbox for verification link.`);
|
|
648
|
+
}
|
|
649
|
+
const channelInfo = channelsAvailable.includes("sms") ? "email and SMS" : "email only (upgrade to Pro for SMS)";
|
|
650
|
+
const quotaInfo = tier === "free" ? `${limits.emailsPerMonth} emails/month` : `${limits.emailsPerMonth} emails + ${limits.smsPerMonth} SMS/month`;
|
|
651
|
+
return {
|
|
652
|
+
success: true,
|
|
653
|
+
api_key: key,
|
|
654
|
+
agent_id: agentId,
|
|
655
|
+
account_status: accountStatus,
|
|
656
|
+
account_created: accountCreated,
|
|
657
|
+
next_steps: nextSteps,
|
|
658
|
+
instructions: `Agent "${name}" registered successfully!
|
|
659
|
+
|
|
660
|
+
ACCOUNT: ${user.email} (${tier} tier)
|
|
661
|
+
CHANNELS: ${channelInfo}
|
|
662
|
+
QUOTA: ${quotaInfo}
|
|
663
|
+
|
|
664
|
+
IMPORTANT: Save this API key - it will NOT be shown again:
|
|
665
|
+
${key}
|
|
666
|
+
|
|
667
|
+
QUICK START:
|
|
668
|
+
send_notification({message: "Test from RelayRail"})
|
|
669
|
+
|
|
670
|
+
MCP CONFIG:
|
|
671
|
+
{
|
|
672
|
+
"mcpServers": {
|
|
673
|
+
"relayrail": {
|
|
674
|
+
"command": "npx",
|
|
675
|
+
"args": ["@relayrail/server", "start"],
|
|
676
|
+
"env": { "RELAYRAIL_API_KEY": "${key}" }
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
AVAILABLE TOOLS:
|
|
682
|
+
- send_notification: Send one-way messages
|
|
683
|
+
- request_approval: Ask user to approve/reject
|
|
684
|
+
- await_response: Wait for user reply
|
|
685
|
+
- get_pending_commands: Get commands from user
|
|
686
|
+
- get_account_status: Check quota and channels`
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// src/tools/get-account-status.ts
|
|
691
|
+
async function getAccountStatus(context) {
|
|
692
|
+
const { supabase, agent, user } = context;
|
|
693
|
+
const usageResult = await supabase.rpc("get_or_create_usage", {
|
|
694
|
+
p_user_id: user.id
|
|
695
|
+
});
|
|
696
|
+
const usage = usageResult.data;
|
|
697
|
+
const tier = user.tier;
|
|
698
|
+
const limits = getTierLimits(tier);
|
|
699
|
+
const emailsUsed = usage?.emails_sent ?? 0;
|
|
700
|
+
const smsUsed = usage?.sms_sent ?? 0;
|
|
701
|
+
const channels = ["email"];
|
|
702
|
+
if (tier !== "free" && limits.smsEnabled && user.phone) {
|
|
703
|
+
channels.push("sms");
|
|
704
|
+
}
|
|
705
|
+
const tips = [];
|
|
706
|
+
if (tier === "free") {
|
|
707
|
+
tips.push("Upgrade to Pro for SMS notifications and 1000 emails/month.");
|
|
708
|
+
}
|
|
709
|
+
if (!user.phone && tier !== "free") {
|
|
710
|
+
tips.push("Add a phone number in settings to enable SMS notifications.");
|
|
711
|
+
}
|
|
712
|
+
const preferredChannel = user.notification_preferences?.preferred_channel || "email";
|
|
713
|
+
if (preferredChannel === "sms" && !channels.includes("sms")) {
|
|
714
|
+
tips.push("Your preferred channel is SMS but it's not available. Messages will be sent via email.");
|
|
715
|
+
}
|
|
716
|
+
const emailRemaining = Math.max(0, limits.emailsPerMonth - emailsUsed);
|
|
717
|
+
if (emailRemaining < 20) {
|
|
718
|
+
tips.push(`Low email quota: ${emailRemaining} remaining this month.`);
|
|
719
|
+
}
|
|
720
|
+
if (tips.length === 0) {
|
|
721
|
+
tips.push("Your account is ready to send notifications!");
|
|
722
|
+
}
|
|
723
|
+
return {
|
|
724
|
+
email: user.email,
|
|
725
|
+
tier,
|
|
726
|
+
email_verified: user.email_verified ?? false,
|
|
727
|
+
phone_configured: !!user.phone,
|
|
728
|
+
channels_available: channels,
|
|
729
|
+
preferred_channel: preferredChannel,
|
|
730
|
+
quota: {
|
|
731
|
+
email: {
|
|
732
|
+
used: emailsUsed,
|
|
733
|
+
limit: limits.emailsPerMonth,
|
|
734
|
+
remaining: emailRemaining,
|
|
735
|
+
overage_enabled: user.allow_email_overage ?? true
|
|
736
|
+
},
|
|
737
|
+
sms: {
|
|
738
|
+
used: smsUsed,
|
|
739
|
+
limit: limits.smsPerMonth,
|
|
740
|
+
remaining: Math.max(0, limits.smsPerMonth - smsUsed),
|
|
741
|
+
overage_enabled: user.allow_sms_overage ?? true,
|
|
742
|
+
available: limits.smsEnabled && !!user.phone
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
agent: {
|
|
746
|
+
id: agent.id,
|
|
747
|
+
name: agent.name,
|
|
748
|
+
created_at: agent.created_at
|
|
749
|
+
},
|
|
750
|
+
tips
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// src/services/email.ts
|
|
755
|
+
import { Resend } from "resend";
|
|
756
|
+
import { encode } from "html-entities";
|
|
757
|
+
function escapeHtml(text) {
|
|
758
|
+
return encode(text);
|
|
759
|
+
}
|
|
760
|
+
function sanitizeUrl(url) {
|
|
761
|
+
try {
|
|
762
|
+
const parsed = new URL(url);
|
|
763
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
764
|
+
return "#";
|
|
765
|
+
}
|
|
766
|
+
return url;
|
|
767
|
+
} catch {
|
|
768
|
+
return "#";
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
var EmailService = class {
|
|
772
|
+
resend;
|
|
773
|
+
config;
|
|
774
|
+
supabase;
|
|
775
|
+
constructor(config, supabase) {
|
|
776
|
+
this.config = config;
|
|
777
|
+
this.supabase = supabase;
|
|
778
|
+
this.resend = new Resend(config.resendApiKey);
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Send an approval request email
|
|
782
|
+
*/
|
|
783
|
+
async sendApprovalEmail(params) {
|
|
784
|
+
const {
|
|
785
|
+
to,
|
|
786
|
+
requestId,
|
|
787
|
+
responseToken,
|
|
788
|
+
message,
|
|
789
|
+
options,
|
|
790
|
+
agentName,
|
|
791
|
+
expiresAt,
|
|
792
|
+
severity = "info"
|
|
793
|
+
} = params;
|
|
794
|
+
const responseUrl = sanitizeUrl(`${this.config.baseUrl}/respond/${encodeURIComponent(requestId)}?token=${encodeURIComponent(responseToken)}`);
|
|
795
|
+
const expiresDate = new Date(expiresAt).toLocaleString();
|
|
796
|
+
const safeMessage = escapeHtml(message);
|
|
797
|
+
const safeAgentName = escapeHtml(agentName);
|
|
798
|
+
const safeRequestId = escapeHtml(requestId);
|
|
799
|
+
const severityColors = {
|
|
800
|
+
info: "#3B82F6",
|
|
801
|
+
warning: "#F59E0B",
|
|
802
|
+
critical: "#EF4444"
|
|
803
|
+
};
|
|
804
|
+
const severityColor = severityColors[severity];
|
|
805
|
+
const optionsHtml = options?.length ? `
|
|
806
|
+
<p style="margin: 16px 0 8px 0; color: #6B7280;">Quick responses:</p>
|
|
807
|
+
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
|
808
|
+
${options.map((opt) => {
|
|
809
|
+
const safeOpt = escapeHtml(opt);
|
|
810
|
+
const optUrl = sanitizeUrl(`${responseUrl}&choice=${encodeURIComponent(opt)}`);
|
|
811
|
+
return `
|
|
812
|
+
<a href="${optUrl}"
|
|
813
|
+
style="display: inline-block; padding: 8px 16px; background: #F3F4F6; color: #374151; text-decoration: none; border-radius: 6px; font-size: 14px;">
|
|
814
|
+
${safeOpt}
|
|
815
|
+
</a>
|
|
816
|
+
`;
|
|
817
|
+
}).join("")}
|
|
818
|
+
</div>
|
|
819
|
+
` : "";
|
|
820
|
+
const html = `
|
|
821
|
+
<!DOCTYPE html>
|
|
822
|
+
<html>
|
|
823
|
+
<head>
|
|
824
|
+
<meta charset="utf-8">
|
|
825
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
826
|
+
</head>
|
|
827
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #F9FAFB; padding: 40px 20px;">
|
|
828
|
+
<div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
|
829
|
+
<div style="padding: 24px; border-bottom: 1px solid #E5E7EB;">
|
|
830
|
+
<div style="display: flex; align-items: center; gap: 12px;">
|
|
831
|
+
<span style="display: inline-block; padding: 4px 8px; background: ${severityColor}20; color: ${severityColor}; border-radius: 4px; font-size: 12px; font-weight: 600; text-transform: uppercase;">
|
|
832
|
+
${severity}
|
|
833
|
+
</span>
|
|
834
|
+
<span style="color: #6B7280; font-size: 14px;">from ${safeAgentName}</span>
|
|
835
|
+
</div>
|
|
836
|
+
</div>
|
|
837
|
+
|
|
838
|
+
<div style="padding: 24px;">
|
|
839
|
+
<h1 style="margin: 0 0 16px 0; font-size: 20px; color: #111827;">Approval Required</h1>
|
|
840
|
+
<p style="margin: 0 0 24px 0; color: #374151; line-height: 1.6; white-space: pre-wrap;">${safeMessage}</p>
|
|
841
|
+
|
|
842
|
+
<a href="${responseUrl}" style="display: inline-block; padding: 12px 24px; background: #2563EB; color: white; text-decoration: none; border-radius: 6px; font-weight: 500;">
|
|
843
|
+
Respond Now
|
|
844
|
+
</a>
|
|
845
|
+
|
|
846
|
+
${optionsHtml}
|
|
847
|
+
</div>
|
|
848
|
+
|
|
849
|
+
<div style="padding: 16px 24px; background: #F9FAFB; border-top: 1px solid #E5E7EB; border-radius: 0 0 8px 8px;">
|
|
850
|
+
<p style="margin: 0; color: #9CA3AF; font-size: 12px;">
|
|
851
|
+
Request ID: ${safeRequestId} \u2022 Expires: ${escapeHtml(expiresDate)}
|
|
852
|
+
</p>
|
|
853
|
+
</div>
|
|
854
|
+
</div>
|
|
855
|
+
|
|
856
|
+
<p style="text-align: center; margin-top: 24px; color: #9CA3AF; font-size: 12px;">
|
|
857
|
+
Sent by <a href="${sanitizeUrl(this.config.baseUrl)}" style="color: #6B7280;">RelayRail</a>
|
|
858
|
+
</p>
|
|
859
|
+
</body>
|
|
860
|
+
</html>
|
|
861
|
+
`;
|
|
862
|
+
try {
|
|
863
|
+
const emailOptions = {
|
|
864
|
+
from: `${this.config.fromName} <${this.config.fromEmail}>`,
|
|
865
|
+
to: [to],
|
|
866
|
+
subject: `[${severity.toUpperCase()}] Approval needed: ${message.substring(0, 50)}${message.length > 50 ? "..." : ""}`,
|
|
867
|
+
html
|
|
868
|
+
};
|
|
869
|
+
if (this.config.inboundDomain) {
|
|
870
|
+
const replyToAddress = `request-${requestId}@${this.config.inboundDomain}`;
|
|
871
|
+
emailOptions.replyTo = replyToAddress;
|
|
872
|
+
}
|
|
873
|
+
const result = await this.resend.emails.send(emailOptions);
|
|
874
|
+
await this.logMessage({
|
|
875
|
+
request_id: requestId,
|
|
876
|
+
channel: "email",
|
|
877
|
+
direction: "outbound",
|
|
878
|
+
external_id: result.data?.id || null,
|
|
879
|
+
status: result.data?.id ? "sent" : "failed",
|
|
880
|
+
content: { to, subject: "Approval Request", type: "approval" },
|
|
881
|
+
error_message: result.error?.message || null
|
|
882
|
+
});
|
|
883
|
+
if (result.error) {
|
|
884
|
+
return { success: false, error: result.error.message };
|
|
885
|
+
}
|
|
886
|
+
return { success: true, messageId: result.data?.id };
|
|
887
|
+
} catch (error) {
|
|
888
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
889
|
+
await this.logMessage({
|
|
890
|
+
request_id: requestId,
|
|
891
|
+
channel: "email",
|
|
892
|
+
direction: "outbound",
|
|
893
|
+
external_id: null,
|
|
894
|
+
status: "failed",
|
|
895
|
+
content: { to, subject: "Approval Request", type: "approval" },
|
|
896
|
+
error_message: errorMessage
|
|
897
|
+
});
|
|
898
|
+
return { success: false, error: errorMessage };
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Send a notification email
|
|
903
|
+
*/
|
|
904
|
+
async sendNotificationEmail(params) {
|
|
905
|
+
const { to, requestId, message, agentName, severity = "info" } = params;
|
|
906
|
+
const safeMessage = escapeHtml(message);
|
|
907
|
+
const safeAgentName = escapeHtml(agentName);
|
|
908
|
+
const safeRequestId = escapeHtml(requestId);
|
|
909
|
+
const severityColors = {
|
|
910
|
+
info: "#3B82F6",
|
|
911
|
+
warning: "#F59E0B",
|
|
912
|
+
critical: "#EF4444"
|
|
913
|
+
};
|
|
914
|
+
const severityColor = severityColors[severity];
|
|
915
|
+
const html = `
|
|
916
|
+
<!DOCTYPE html>
|
|
917
|
+
<html>
|
|
918
|
+
<head>
|
|
919
|
+
<meta charset="utf-8">
|
|
920
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
921
|
+
</head>
|
|
922
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #F9FAFB; padding: 40px 20px;">
|
|
923
|
+
<div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
|
924
|
+
<div style="padding: 24px; border-bottom: 1px solid #E5E7EB;">
|
|
925
|
+
<div style="display: flex; align-items: center; gap: 12px;">
|
|
926
|
+
<span style="display: inline-block; padding: 4px 8px; background: ${severityColor}20; color: ${severityColor}; border-radius: 4px; font-size: 12px; font-weight: 600; text-transform: uppercase;">
|
|
927
|
+
${severity}
|
|
928
|
+
</span>
|
|
929
|
+
<span style="color: #6B7280; font-size: 14px;">from ${safeAgentName}</span>
|
|
930
|
+
</div>
|
|
931
|
+
</div>
|
|
932
|
+
|
|
933
|
+
<div style="padding: 24px;">
|
|
934
|
+
<h1 style="margin: 0 0 16px 0; font-size: 20px; color: #111827;">Notification</h1>
|
|
935
|
+
<p style="margin: 0; color: #374151; line-height: 1.6; white-space: pre-wrap;">${safeMessage}</p>
|
|
936
|
+
</div>
|
|
937
|
+
|
|
938
|
+
<div style="padding: 16px 24px; background: #F9FAFB; border-top: 1px solid #E5E7EB; border-radius: 0 0 8px 8px;">
|
|
939
|
+
<p style="margin: 0; color: #9CA3AF; font-size: 12px;">
|
|
940
|
+
Request ID: ${safeRequestId}
|
|
941
|
+
</p>
|
|
942
|
+
</div>
|
|
943
|
+
</div>
|
|
944
|
+
|
|
945
|
+
<p style="text-align: center; margin-top: 24px; color: #9CA3AF; font-size: 12px;">
|
|
946
|
+
Sent by <a href="${sanitizeUrl(this.config.baseUrl)}" style="color: #6B7280;">RelayRail</a>
|
|
947
|
+
</p>
|
|
948
|
+
</body>
|
|
949
|
+
</html>
|
|
950
|
+
`;
|
|
951
|
+
try {
|
|
952
|
+
const result = await this.resend.emails.send({
|
|
953
|
+
from: `${this.config.fromName} <${this.config.fromEmail}>`,
|
|
954
|
+
to: [to],
|
|
955
|
+
subject: `[${severity.toUpperCase()}] ${agentName}: ${message.substring(0, 50)}${message.length > 50 ? "..." : ""}`,
|
|
956
|
+
html
|
|
957
|
+
});
|
|
958
|
+
await this.logMessage({
|
|
959
|
+
request_id: requestId,
|
|
960
|
+
channel: "email",
|
|
961
|
+
direction: "outbound",
|
|
962
|
+
external_id: result.data?.id || null,
|
|
963
|
+
status: result.data?.id ? "sent" : "failed",
|
|
964
|
+
content: { to, subject: "Notification", type: "notification" },
|
|
965
|
+
error_message: result.error?.message || null
|
|
966
|
+
});
|
|
967
|
+
if (result.data?.id) {
|
|
968
|
+
await this.supabase.from("requests").update({ status: "delivered" }).eq("id", requestId);
|
|
969
|
+
}
|
|
970
|
+
if (result.error) {
|
|
971
|
+
return { success: false, error: result.error.message };
|
|
972
|
+
}
|
|
973
|
+
return { success: true, messageId: result.data?.id };
|
|
974
|
+
} catch (error) {
|
|
975
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
976
|
+
await this.logMessage({
|
|
977
|
+
request_id: requestId,
|
|
978
|
+
channel: "email",
|
|
979
|
+
direction: "outbound",
|
|
980
|
+
external_id: null,
|
|
981
|
+
status: "failed",
|
|
982
|
+
content: { to, subject: "Notification", type: "notification" },
|
|
983
|
+
error_message: errorMessage
|
|
984
|
+
});
|
|
985
|
+
return { success: false, error: errorMessage };
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Log a message to the database
|
|
990
|
+
*/
|
|
991
|
+
async logMessage(data) {
|
|
992
|
+
try {
|
|
993
|
+
await this.supabase.from("messages").insert(data);
|
|
994
|
+
} catch (error) {
|
|
995
|
+
console.error("[RelayRail] Failed to log message:", error);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
// src/services/sms.ts
|
|
1001
|
+
import { Telnyx } from "telnyx";
|
|
1002
|
+
var SmsService = class {
|
|
1003
|
+
client;
|
|
1004
|
+
config;
|
|
1005
|
+
supabase;
|
|
1006
|
+
constructor(config, supabase) {
|
|
1007
|
+
this.config = config;
|
|
1008
|
+
this.supabase = supabase;
|
|
1009
|
+
this.client = new Telnyx({ apiKey: config.apiKey });
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Send an approval request via SMS
|
|
1013
|
+
*/
|
|
1014
|
+
async sendApprovalSms(params) {
|
|
1015
|
+
const {
|
|
1016
|
+
to,
|
|
1017
|
+
requestId,
|
|
1018
|
+
responseToken,
|
|
1019
|
+
message,
|
|
1020
|
+
options,
|
|
1021
|
+
agentName,
|
|
1022
|
+
expiresAt,
|
|
1023
|
+
severity = "info"
|
|
1024
|
+
} = params;
|
|
1025
|
+
const responseUrl = `${this.config.baseUrl}/respond/${requestId}?token=${responseToken}`;
|
|
1026
|
+
const expiresDate = new Date(expiresAt).toLocaleString();
|
|
1027
|
+
const severityPrefix = severity === "critical" ? "!" : severity === "warning" ? "?" : "";
|
|
1028
|
+
let body = `${severityPrefix}[${agentName}] Approval needed:
|
|
1029
|
+
|
|
1030
|
+
${message}
|
|
1031
|
+
|
|
1032
|
+
Respond: ${responseUrl}`;
|
|
1033
|
+
if (options?.length) {
|
|
1034
|
+
body += `
|
|
1035
|
+
|
|
1036
|
+
Reply with: ${options.join(" / ")}`;
|
|
1037
|
+
}
|
|
1038
|
+
body += `
|
|
1039
|
+
|
|
1040
|
+
Expires: ${expiresDate}`;
|
|
1041
|
+
if (body.length > 1500) {
|
|
1042
|
+
body = body.substring(0, 1450) + "...\n\nTap link to respond: " + responseUrl;
|
|
1043
|
+
}
|
|
1044
|
+
const normalizedTo = this.normalizePhoneNumber(to);
|
|
1045
|
+
console.log(`[RelayRail SMS] Sending approval SMS:`, {
|
|
1046
|
+
requestId,
|
|
1047
|
+
from: this.config.fromNumber,
|
|
1048
|
+
to: normalizedTo,
|
|
1049
|
+
bodyLength: body.length
|
|
1050
|
+
});
|
|
1051
|
+
try {
|
|
1052
|
+
const result = await this.client.messages.send({
|
|
1053
|
+
from: this.config.fromNumber,
|
|
1054
|
+
to: normalizedTo,
|
|
1055
|
+
text: body
|
|
1056
|
+
});
|
|
1057
|
+
const messageId = result.data?.id;
|
|
1058
|
+
console.log(`[RelayRail SMS] Telnyx response:`, {
|
|
1059
|
+
requestId,
|
|
1060
|
+
messageId,
|
|
1061
|
+
status: result.data?.to?.[0]?.status
|
|
1062
|
+
});
|
|
1063
|
+
await this.logMessage({
|
|
1064
|
+
request_id: requestId,
|
|
1065
|
+
channel: "sms",
|
|
1066
|
+
direction: "outbound",
|
|
1067
|
+
external_id: messageId || null,
|
|
1068
|
+
status: messageId ? "sent" : "pending",
|
|
1069
|
+
content: { to: normalizedTo, type: "approval", messageLength: body.length },
|
|
1070
|
+
error_message: null
|
|
1071
|
+
});
|
|
1072
|
+
return { success: true, messageId };
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1075
|
+
console.error(`[RelayRail SMS] Failed to send:`, {
|
|
1076
|
+
requestId,
|
|
1077
|
+
error: errorMessage,
|
|
1078
|
+
stack: error instanceof Error ? error.stack : void 0
|
|
1079
|
+
});
|
|
1080
|
+
await this.logMessage({
|
|
1081
|
+
request_id: requestId,
|
|
1082
|
+
channel: "sms",
|
|
1083
|
+
direction: "outbound",
|
|
1084
|
+
external_id: null,
|
|
1085
|
+
status: "failed",
|
|
1086
|
+
content: { to: normalizedTo, type: "approval" },
|
|
1087
|
+
error_message: errorMessage
|
|
1088
|
+
});
|
|
1089
|
+
return { success: false, error: errorMessage };
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Send a notification via SMS
|
|
1094
|
+
*/
|
|
1095
|
+
async sendNotificationSms(params) {
|
|
1096
|
+
const { to, requestId, message, agentName, severity = "info" } = params;
|
|
1097
|
+
const severityPrefix = severity === "critical" ? "!" : severity === "warning" ? "?" : "";
|
|
1098
|
+
let body = `${severityPrefix}[${agentName}] ${message}`;
|
|
1099
|
+
if (body.length > 1500) {
|
|
1100
|
+
body = body.substring(0, 1500) + "...";
|
|
1101
|
+
}
|
|
1102
|
+
try {
|
|
1103
|
+
const result = await this.client.messages.send({
|
|
1104
|
+
from: this.config.fromNumber,
|
|
1105
|
+
to: this.normalizePhoneNumber(to),
|
|
1106
|
+
text: body
|
|
1107
|
+
});
|
|
1108
|
+
const messageId = result.data?.id;
|
|
1109
|
+
await this.logMessage({
|
|
1110
|
+
request_id: requestId,
|
|
1111
|
+
channel: "sms",
|
|
1112
|
+
direction: "outbound",
|
|
1113
|
+
external_id: messageId || null,
|
|
1114
|
+
status: messageId ? "sent" : "pending",
|
|
1115
|
+
content: { to, type: "notification", messageLength: body.length },
|
|
1116
|
+
error_message: null
|
|
1117
|
+
});
|
|
1118
|
+
await this.supabase.from("requests").update({ status: "delivered" }).eq("id", requestId);
|
|
1119
|
+
return { success: true, messageId };
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1122
|
+
await this.logMessage({
|
|
1123
|
+
request_id: requestId,
|
|
1124
|
+
channel: "sms",
|
|
1125
|
+
direction: "outbound",
|
|
1126
|
+
external_id: null,
|
|
1127
|
+
status: "failed",
|
|
1128
|
+
content: { to, type: "notification" },
|
|
1129
|
+
error_message: errorMessage
|
|
1130
|
+
});
|
|
1131
|
+
return { success: false, error: errorMessage };
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Normalize phone number to E.164 format
|
|
1136
|
+
*/
|
|
1137
|
+
normalizePhoneNumber(phone) {
|
|
1138
|
+
let normalized = phone.replace(/[^\d+]/g, "");
|
|
1139
|
+
if (!normalized.startsWith("+") && normalized.length === 10) {
|
|
1140
|
+
normalized = "+1" + normalized;
|
|
1141
|
+
}
|
|
1142
|
+
if (!normalized.startsWith("+")) {
|
|
1143
|
+
normalized = "+" + normalized;
|
|
1144
|
+
}
|
|
1145
|
+
return normalized;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Log a message to the database
|
|
1149
|
+
*/
|
|
1150
|
+
async logMessage(data) {
|
|
1151
|
+
try {
|
|
1152
|
+
await this.supabase.from("messages").insert(data);
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
console.error("[RelayRail] Failed to log message:", error);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1159
|
+
// src/services/usage.ts
|
|
1160
|
+
var UsageService = class {
|
|
1161
|
+
supabase;
|
|
1162
|
+
baseUrl;
|
|
1163
|
+
constructor(supabase, baseUrl) {
|
|
1164
|
+
this.supabase = supabase;
|
|
1165
|
+
this.baseUrl = baseUrl;
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Get current usage for a user
|
|
1169
|
+
*/
|
|
1170
|
+
async getUsage(userId) {
|
|
1171
|
+
const result = await this.supabase.rpc("get_or_create_usage", {
|
|
1172
|
+
p_user_id: userId
|
|
1173
|
+
});
|
|
1174
|
+
if (result.error) {
|
|
1175
|
+
console.error("Failed to get usage:", result.error);
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
return result.data;
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Check email quota for a user
|
|
1182
|
+
*/
|
|
1183
|
+
checkEmailQuota(user, currentUsage) {
|
|
1184
|
+
return checkEmailQuota(
|
|
1185
|
+
user.tier,
|
|
1186
|
+
currentUsage,
|
|
1187
|
+
user.allow_email_overage ?? true,
|
|
1188
|
+
this.baseUrl
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Check SMS quota for a user
|
|
1193
|
+
*/
|
|
1194
|
+
checkSmsQuota(user, currentUsage) {
|
|
1195
|
+
return checkSmsQuota(
|
|
1196
|
+
user.tier,
|
|
1197
|
+
currentUsage,
|
|
1198
|
+
user.allow_sms_overage ?? true,
|
|
1199
|
+
this.baseUrl
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Increment email usage with overage tracking
|
|
1204
|
+
* Returns whether the send was allowed and if it was an overage
|
|
1205
|
+
*/
|
|
1206
|
+
async incrementEmailUsage(userId, allowOverage) {
|
|
1207
|
+
const result = await this.supabase.rpc("increment_email_usage_with_overage", {
|
|
1208
|
+
p_user_id: userId,
|
|
1209
|
+
p_allow_overage: allowOverage
|
|
1210
|
+
});
|
|
1211
|
+
if (result.error) {
|
|
1212
|
+
console.error("Failed to increment email usage:", result.error);
|
|
1213
|
+
return { count: 0, isOverage: false, blocked: true };
|
|
1214
|
+
}
|
|
1215
|
+
const data = result.data;
|
|
1216
|
+
if (!data || data.length === 0) {
|
|
1217
|
+
return { count: 0, isOverage: false, blocked: true };
|
|
1218
|
+
}
|
|
1219
|
+
const row = data[0];
|
|
1220
|
+
return {
|
|
1221
|
+
count: row.new_count,
|
|
1222
|
+
isOverage: row.is_overage,
|
|
1223
|
+
blocked: row.was_blocked
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Increment SMS usage with overage tracking
|
|
1228
|
+
* Returns whether the send was allowed and if it was an overage
|
|
1229
|
+
*/
|
|
1230
|
+
async incrementSmsUsage(userId, allowOverage) {
|
|
1231
|
+
const result = await this.supabase.rpc("increment_sms_usage_with_overage", {
|
|
1232
|
+
p_user_id: userId,
|
|
1233
|
+
p_allow_overage: allowOverage
|
|
1234
|
+
});
|
|
1235
|
+
if (result.error) {
|
|
1236
|
+
console.error("Failed to increment SMS usage:", result.error);
|
|
1237
|
+
return { count: 0, isOverage: false, blocked: true };
|
|
1238
|
+
}
|
|
1239
|
+
const data = result.data;
|
|
1240
|
+
if (!data || data.length === 0) {
|
|
1241
|
+
return { count: 0, isOverage: false, blocked: true };
|
|
1242
|
+
}
|
|
1243
|
+
const row = data[0];
|
|
1244
|
+
return {
|
|
1245
|
+
count: row.new_count,
|
|
1246
|
+
isOverage: row.is_overage,
|
|
1247
|
+
blocked: row.was_blocked
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Record an overage charge for billing
|
|
1252
|
+
*/
|
|
1253
|
+
async recordOverageCharge(userId, channel, requestId) {
|
|
1254
|
+
const amountCents = channel === "email" ? EMAIL_OVERAGE_CENTS : SMS_OVERAGE_CENTS;
|
|
1255
|
+
const result = await this.supabase.rpc("record_overage_charge", {
|
|
1256
|
+
p_user_id: userId,
|
|
1257
|
+
p_channel: channel,
|
|
1258
|
+
p_amount_cents: amountCents,
|
|
1259
|
+
p_request_id: requestId ?? null
|
|
1260
|
+
});
|
|
1261
|
+
if (result.error) {
|
|
1262
|
+
console.error("Failed to record overage charge:", result.error);
|
|
1263
|
+
return null;
|
|
1264
|
+
}
|
|
1265
|
+
return result.data;
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Get email limit for a user's tier
|
|
1269
|
+
*/
|
|
1270
|
+
async getEmailLimit(userId) {
|
|
1271
|
+
const result = await this.supabase.rpc("get_email_limit", {
|
|
1272
|
+
p_user_id: userId
|
|
1273
|
+
});
|
|
1274
|
+
if (result.error) {
|
|
1275
|
+
console.error("Failed to get email limit:", result.error);
|
|
1276
|
+
return 100;
|
|
1277
|
+
}
|
|
1278
|
+
return result.data;
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Get SMS limit for a user's tier
|
|
1282
|
+
*/
|
|
1283
|
+
async getSmsLimit(userId) {
|
|
1284
|
+
const result = await this.supabase.rpc("get_sms_limit", {
|
|
1285
|
+
p_user_id: userId
|
|
1286
|
+
});
|
|
1287
|
+
if (result.error) {
|
|
1288
|
+
console.error("Failed to get SMS limit:", result.error);
|
|
1289
|
+
return 0;
|
|
1290
|
+
}
|
|
1291
|
+
return result.data;
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
|
|
1295
|
+
// src/services/router.ts
|
|
1296
|
+
var RequestRouter = class {
|
|
1297
|
+
emailService;
|
|
1298
|
+
smsService;
|
|
1299
|
+
usageService;
|
|
1300
|
+
smsEnabled;
|
|
1301
|
+
baseUrl;
|
|
1302
|
+
constructor(config, supabase) {
|
|
1303
|
+
this.emailService = new EmailService(config.email, supabase);
|
|
1304
|
+
this.usageService = new UsageService(supabase, config.baseUrl);
|
|
1305
|
+
this.baseUrl = config.baseUrl;
|
|
1306
|
+
this.smsEnabled = !!config.sms?.apiKey;
|
|
1307
|
+
if (config.sms) {
|
|
1308
|
+
this.smsService = new SmsService(config.sms, supabase);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Route an approval request to the user
|
|
1313
|
+
*/
|
|
1314
|
+
async routeApproval(params, user, agent) {
|
|
1315
|
+
const preferredChannel = user.notification_preferences?.preferred_channel || "email";
|
|
1316
|
+
const { channel, fallbackReason } = this.selectChannel(preferredChannel, user);
|
|
1317
|
+
console.log(`[RelayRail Router] Routing approval:`, {
|
|
1318
|
+
requestId: params.requestId,
|
|
1319
|
+
preferredChannel,
|
|
1320
|
+
selectedChannel: channel,
|
|
1321
|
+
fallbackReason,
|
|
1322
|
+
userPhone: user.phone ? `${user.phone.slice(0, 4)}...` : "none",
|
|
1323
|
+
userTier: user.tier,
|
|
1324
|
+
smsEnabled: this.smsEnabled,
|
|
1325
|
+
hasSmsService: !!this.smsService
|
|
1326
|
+
});
|
|
1327
|
+
const usage = await this.usageService.getUsage(user.id);
|
|
1328
|
+
const currentUsage = channel === "sms" ? usage?.sms_sent ?? 0 : usage?.emails_sent ?? 0;
|
|
1329
|
+
const quotaCheck = channel === "sms" ? this.usageService.checkSmsQuota(user, currentUsage) : this.usageService.checkEmailQuota(user, currentUsage);
|
|
1330
|
+
if (!quotaCheck.allowed) {
|
|
1331
|
+
return this.createQuotaErrorResult(channel, quotaCheck, user);
|
|
1332
|
+
}
|
|
1333
|
+
const allowOverage = channel === "sms" ? user.allow_sms_overage ?? true : user.allow_email_overage ?? true;
|
|
1334
|
+
const usageResult = channel === "sms" ? await this.usageService.incrementSmsUsage(user.id, allowOverage) : await this.usageService.incrementEmailUsage(user.id, allowOverage);
|
|
1335
|
+
if (usageResult.blocked) {
|
|
1336
|
+
return this.createQuotaErrorResult(channel, quotaCheck, user);
|
|
1337
|
+
}
|
|
1338
|
+
if (usageResult.isOverage) {
|
|
1339
|
+
await this.usageService.recordOverageCharge(user.id, channel, params.requestId);
|
|
1340
|
+
}
|
|
1341
|
+
let sendResult;
|
|
1342
|
+
if (channel === "sms" && this.smsService && user.phone) {
|
|
1343
|
+
sendResult = await this.smsService.sendApprovalSms({
|
|
1344
|
+
to: user.phone,
|
|
1345
|
+
requestId: params.requestId,
|
|
1346
|
+
responseToken: params.responseToken,
|
|
1347
|
+
message: params.message,
|
|
1348
|
+
options: params.options,
|
|
1349
|
+
agentName: agent.name,
|
|
1350
|
+
expiresAt: params.expiresAt,
|
|
1351
|
+
severity: params.severity
|
|
1352
|
+
});
|
|
1353
|
+
} else {
|
|
1354
|
+
sendResult = await this.emailService.sendApprovalEmail({
|
|
1355
|
+
to: user.email,
|
|
1356
|
+
requestId: params.requestId,
|
|
1357
|
+
responseToken: params.responseToken,
|
|
1358
|
+
message: params.message,
|
|
1359
|
+
options: params.options,
|
|
1360
|
+
agentName: agent.name,
|
|
1361
|
+
expiresAt: params.expiresAt,
|
|
1362
|
+
severity: params.severity
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
return {
|
|
1366
|
+
success: sendResult.success,
|
|
1367
|
+
channel,
|
|
1368
|
+
channel_requested: preferredChannel,
|
|
1369
|
+
fallback_reason: fallbackReason,
|
|
1370
|
+
messageId: sendResult.messageId,
|
|
1371
|
+
error: sendResult.error,
|
|
1372
|
+
quota: this.createQuotaInfo(usageResult, quotaCheck, channel)
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Route a notification to the user
|
|
1377
|
+
*/
|
|
1378
|
+
async routeNotification(params, user, agent) {
|
|
1379
|
+
const preferredChannel = user.notification_preferences?.preferred_channel || "email";
|
|
1380
|
+
const { channel, fallbackReason } = this.selectChannel(preferredChannel, user);
|
|
1381
|
+
const usage = await this.usageService.getUsage(user.id);
|
|
1382
|
+
const currentUsage = channel === "sms" ? usage?.sms_sent ?? 0 : usage?.emails_sent ?? 0;
|
|
1383
|
+
const quotaCheck = channel === "sms" ? this.usageService.checkSmsQuota(user, currentUsage) : this.usageService.checkEmailQuota(user, currentUsage);
|
|
1384
|
+
if (!quotaCheck.allowed) {
|
|
1385
|
+
return this.createQuotaErrorResult(channel, quotaCheck, user);
|
|
1386
|
+
}
|
|
1387
|
+
const allowOverage = channel === "sms" ? user.allow_sms_overage ?? true : user.allow_email_overage ?? true;
|
|
1388
|
+
const usageResult = channel === "sms" ? await this.usageService.incrementSmsUsage(user.id, allowOverage) : await this.usageService.incrementEmailUsage(user.id, allowOverage);
|
|
1389
|
+
if (usageResult.blocked) {
|
|
1390
|
+
return this.createQuotaErrorResult(channel, quotaCheck, user);
|
|
1391
|
+
}
|
|
1392
|
+
if (usageResult.isOverage) {
|
|
1393
|
+
await this.usageService.recordOverageCharge(user.id, channel, params.requestId);
|
|
1394
|
+
}
|
|
1395
|
+
let sendResult;
|
|
1396
|
+
if (channel === "sms" && this.smsService && user.phone) {
|
|
1397
|
+
sendResult = await this.smsService.sendNotificationSms({
|
|
1398
|
+
to: user.phone,
|
|
1399
|
+
requestId: params.requestId,
|
|
1400
|
+
message: params.message,
|
|
1401
|
+
agentName: agent.name,
|
|
1402
|
+
severity: params.severity
|
|
1403
|
+
});
|
|
1404
|
+
} else {
|
|
1405
|
+
sendResult = await this.emailService.sendNotificationEmail({
|
|
1406
|
+
to: user.email,
|
|
1407
|
+
requestId: params.requestId,
|
|
1408
|
+
message: params.message,
|
|
1409
|
+
agentName: agent.name,
|
|
1410
|
+
severity: params.severity
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
return {
|
|
1414
|
+
success: sendResult.success,
|
|
1415
|
+
channel,
|
|
1416
|
+
channel_requested: preferredChannel,
|
|
1417
|
+
fallback_reason: fallbackReason,
|
|
1418
|
+
messageId: sendResult.messageId,
|
|
1419
|
+
error: sendResult.error,
|
|
1420
|
+
quota: this.createQuotaInfo(usageResult, quotaCheck, channel)
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Select the best available channel with fallback logic
|
|
1425
|
+
* Returns the channel to use and a reason if fallback occurred
|
|
1426
|
+
*/
|
|
1427
|
+
selectChannel(preferred, user) {
|
|
1428
|
+
const tier = user.tier;
|
|
1429
|
+
const smsAvailable = tier !== "free" && !!user.phone && this.smsEnabled && !!this.smsService;
|
|
1430
|
+
if (preferred === "sms") {
|
|
1431
|
+
if (smsAvailable) {
|
|
1432
|
+
return { channel: "sms" };
|
|
1433
|
+
}
|
|
1434
|
+
let reason;
|
|
1435
|
+
if (tier === "free") {
|
|
1436
|
+
reason = "SMS requires Pro tier. Sent via email instead.";
|
|
1437
|
+
} else if (!user.phone) {
|
|
1438
|
+
reason = "No phone number configured. Sent via email instead.";
|
|
1439
|
+
} else if (!this.smsEnabled || !this.smsService) {
|
|
1440
|
+
reason = "SMS service not configured. Sent via email instead.";
|
|
1441
|
+
} else {
|
|
1442
|
+
reason = "SMS not available. Sent via email instead.";
|
|
1443
|
+
}
|
|
1444
|
+
return { channel: "email", fallbackReason: reason };
|
|
1445
|
+
}
|
|
1446
|
+
return { channel: "email" };
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Create quota info object for successful sends
|
|
1450
|
+
*/
|
|
1451
|
+
createQuotaInfo(usageResult, quotaCheck, channel) {
|
|
1452
|
+
const overageRate = channel === "sms" ? SMS_OVERAGE_CENTS / 100 : EMAIL_OVERAGE_CENTS / 100;
|
|
1453
|
+
const info = {
|
|
1454
|
+
used: usageResult.count,
|
|
1455
|
+
limit: quotaCheck.limit,
|
|
1456
|
+
remaining: Math.max(0, quotaCheck.limit - usageResult.count)
|
|
1457
|
+
};
|
|
1458
|
+
if (usageResult.isOverage) {
|
|
1459
|
+
info.overage = true;
|
|
1460
|
+
info.overage_rate = overageRate;
|
|
1461
|
+
info.message = `${channel.toUpperCase()} sent as overage. You will be charged $${overageRate.toFixed(2)} for this message.`;
|
|
1462
|
+
}
|
|
1463
|
+
return info;
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Create quota error result when send is blocked
|
|
1467
|
+
*/
|
|
1468
|
+
createQuotaErrorResult(channel, quotaCheck, user) {
|
|
1469
|
+
const tier = user.tier;
|
|
1470
|
+
const allowOverage = channel === "sms" ? user.allow_sms_overage ?? true : user.allow_email_overage ?? true;
|
|
1471
|
+
let message;
|
|
1472
|
+
if (tier === "free" && channel === "sms") {
|
|
1473
|
+
message = "SMS is not available on the Free tier. Upgrade to Pro for 100 SMS/month.";
|
|
1474
|
+
} else if (!allowOverage) {
|
|
1475
|
+
message = `${channel.toUpperCase()} limit reached (${quotaCheck.current}/${quotaCheck.limit}). Overage charges are disabled in your settings.`;
|
|
1476
|
+
} else {
|
|
1477
|
+
message = quotaCheck.message || `${channel.toUpperCase()} limit reached.`;
|
|
1478
|
+
}
|
|
1479
|
+
return {
|
|
1480
|
+
success: false,
|
|
1481
|
+
channel,
|
|
1482
|
+
error: message,
|
|
1483
|
+
quotaError: {
|
|
1484
|
+
exceeded: true,
|
|
1485
|
+
current: quotaCheck.current,
|
|
1486
|
+
limit: quotaCheck.limit,
|
|
1487
|
+
tier,
|
|
1488
|
+
overage_disabled: !allowOverage,
|
|
1489
|
+
message,
|
|
1490
|
+
enable_overage_url: !allowOverage ? `${sanitizeBaseUrl(this.baseUrl)}/settings?section=billing` : void 0,
|
|
1491
|
+
upgradeUrl: `${sanitizeBaseUrl(this.baseUrl)}/pricing?upgrade=true&reason=${channel}_limit`
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
};
|
|
1496
|
+
function createRouter(config, supabase) {
|
|
1497
|
+
return new RequestRouter(config, supabase);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// src/server.ts
|
|
1501
|
+
var VERSION = "0.1.0";
|
|
1502
|
+
var RelayRailServer = class {
|
|
1503
|
+
server;
|
|
1504
|
+
supabase;
|
|
1505
|
+
config;
|
|
1506
|
+
context = null;
|
|
1507
|
+
router = null;
|
|
1508
|
+
constructor(config) {
|
|
1509
|
+
this.config = config;
|
|
1510
|
+
this.supabase = createServiceClient(config.supabaseUrl, config.supabaseServiceRoleKey);
|
|
1511
|
+
const hasEmail = !!config.resendApiKey;
|
|
1512
|
+
const hasSms = !!(config.telnyxApiKey && config.telnyxPhoneNumber);
|
|
1513
|
+
if (hasEmail || hasSms) {
|
|
1514
|
+
this.router = createRouter(
|
|
1515
|
+
{
|
|
1516
|
+
baseUrl: config.baseUrl,
|
|
1517
|
+
email: {
|
|
1518
|
+
resendApiKey: config.resendApiKey || "",
|
|
1519
|
+
fromEmail: "notifications@mail.relayrail.dev",
|
|
1520
|
+
fromName: "RelayRail",
|
|
1521
|
+
baseUrl: config.baseUrl,
|
|
1522
|
+
inboundDomain: "in.relayrail.dev"
|
|
1523
|
+
},
|
|
1524
|
+
sms: hasSms ? {
|
|
1525
|
+
apiKey: config.telnyxApiKey,
|
|
1526
|
+
fromNumber: config.telnyxPhoneNumber,
|
|
1527
|
+
baseUrl: config.baseUrl
|
|
1528
|
+
} : void 0
|
|
1529
|
+
},
|
|
1530
|
+
this.supabase
|
|
1531
|
+
);
|
|
1532
|
+
}
|
|
1533
|
+
this.server = new McpServer({
|
|
1534
|
+
name: "relayrail",
|
|
1535
|
+
version: VERSION
|
|
1536
|
+
});
|
|
1537
|
+
this.registerTools();
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Register all MCP tools
|
|
1541
|
+
*/
|
|
1542
|
+
registerTools() {
|
|
1543
|
+
this.server.tool(
|
|
1544
|
+
"request_approval",
|
|
1545
|
+
"Request explicit approval from the user before proceeding with an action. The user will receive a notification and can approve or reject.",
|
|
1546
|
+
{
|
|
1547
|
+
message: z.string().describe("The message explaining what approval is being requested"),
|
|
1548
|
+
options: z.array(z.string()).optional().describe("Optional list of response options for the user"),
|
|
1549
|
+
context: z.record(z.unknown()).optional().describe("Additional context data to include"),
|
|
1550
|
+
timeout_minutes: z.number().min(1).max(MAX_TIMEOUT_MINUTES).optional().describe(`Timeout in minutes (default: ${DEFAULT_TIMEOUT_MINUTES}, max: ${MAX_TIMEOUT_MINUTES})`),
|
|
1551
|
+
severity: z.enum(["info", "warning", "critical"]).optional().describe("Severity level of the request")
|
|
1552
|
+
},
|
|
1553
|
+
async (params) => {
|
|
1554
|
+
if (!this.context) {
|
|
1555
|
+
return {
|
|
1556
|
+
content: [{ type: "text", text: JSON.stringify({ error: "Not authenticated" }) }]
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
try {
|
|
1560
|
+
const result = await requestApproval(
|
|
1561
|
+
params,
|
|
1562
|
+
{
|
|
1563
|
+
supabase: this.supabase,
|
|
1564
|
+
agent: this.context.agent,
|
|
1565
|
+
user: this.context.user,
|
|
1566
|
+
baseUrl: this.config.baseUrl,
|
|
1567
|
+
router: this.router ?? void 0
|
|
1568
|
+
}
|
|
1569
|
+
);
|
|
1570
|
+
return {
|
|
1571
|
+
content: [{
|
|
1572
|
+
type: "text",
|
|
1573
|
+
text: JSON.stringify(result)
|
|
1574
|
+
}]
|
|
1575
|
+
};
|
|
1576
|
+
} catch (error) {
|
|
1577
|
+
return {
|
|
1578
|
+
content: [{
|
|
1579
|
+
type: "text",
|
|
1580
|
+
text: JSON.stringify({
|
|
1581
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
1582
|
+
})
|
|
1583
|
+
}]
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
);
|
|
1588
|
+
this.server.tool(
|
|
1589
|
+
"send_notification",
|
|
1590
|
+
"Send a one-way notification to the user. No response is expected.",
|
|
1591
|
+
{
|
|
1592
|
+
message: z.string().describe("The notification message to send"),
|
|
1593
|
+
context: z.record(z.unknown()).optional().describe("Additional context data to include"),
|
|
1594
|
+
severity: z.enum(["info", "warning", "critical"]).optional().describe("Severity level")
|
|
1595
|
+
},
|
|
1596
|
+
async (params) => {
|
|
1597
|
+
if (!this.context) {
|
|
1598
|
+
return {
|
|
1599
|
+
content: [{ type: "text", text: JSON.stringify({ error: "Not authenticated" }) }]
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
try {
|
|
1603
|
+
const result = await sendNotification(
|
|
1604
|
+
params,
|
|
1605
|
+
{
|
|
1606
|
+
supabase: this.supabase,
|
|
1607
|
+
agent: this.context.agent,
|
|
1608
|
+
user: this.context.user,
|
|
1609
|
+
router: this.router ?? void 0
|
|
1610
|
+
}
|
|
1611
|
+
);
|
|
1612
|
+
return {
|
|
1613
|
+
content: [{
|
|
1614
|
+
type: "text",
|
|
1615
|
+
text: JSON.stringify(result)
|
|
1616
|
+
}]
|
|
1617
|
+
};
|
|
1618
|
+
} catch (error) {
|
|
1619
|
+
return {
|
|
1620
|
+
content: [{
|
|
1621
|
+
type: "text",
|
|
1622
|
+
text: JSON.stringify({
|
|
1623
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
1624
|
+
})
|
|
1625
|
+
}]
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
);
|
|
1630
|
+
this.server.tool(
|
|
1631
|
+
"await_response",
|
|
1632
|
+
"Wait for a user response to a previously sent approval request.",
|
|
1633
|
+
{
|
|
1634
|
+
request_id: z.string().describe("The request ID to wait for"),
|
|
1635
|
+
timeout_seconds: z.number().min(1).max(300).optional().describe("How long to wait for a response (default: 30, max: 300)")
|
|
1636
|
+
},
|
|
1637
|
+
async (params) => {
|
|
1638
|
+
if (!this.context) {
|
|
1639
|
+
return {
|
|
1640
|
+
content: [{ type: "text", text: JSON.stringify({ error: "Not authenticated" }) }]
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
try {
|
|
1644
|
+
const result = await awaitResponse(
|
|
1645
|
+
params,
|
|
1646
|
+
{
|
|
1647
|
+
supabase: this.supabase,
|
|
1648
|
+
agent: this.context.agent
|
|
1649
|
+
}
|
|
1650
|
+
);
|
|
1651
|
+
return {
|
|
1652
|
+
content: [{
|
|
1653
|
+
type: "text",
|
|
1654
|
+
text: JSON.stringify(result)
|
|
1655
|
+
}]
|
|
1656
|
+
};
|
|
1657
|
+
} catch (error) {
|
|
1658
|
+
return {
|
|
1659
|
+
content: [{
|
|
1660
|
+
type: "text",
|
|
1661
|
+
text: JSON.stringify({
|
|
1662
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
1663
|
+
})
|
|
1664
|
+
}]
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
);
|
|
1669
|
+
this.server.tool(
|
|
1670
|
+
"get_pending_commands",
|
|
1671
|
+
"Retrieve pending commands sent by the user via SMS or email.",
|
|
1672
|
+
{
|
|
1673
|
+
limit: z.number().min(1).max(100).optional().describe("Maximum number of commands to return (default: 10)")
|
|
1674
|
+
},
|
|
1675
|
+
async (params) => {
|
|
1676
|
+
if (!this.context) {
|
|
1677
|
+
return {
|
|
1678
|
+
content: [{ type: "text", text: JSON.stringify({ error: "Not authenticated" }) }]
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
try {
|
|
1682
|
+
const result = await getPendingCommands(
|
|
1683
|
+
params,
|
|
1684
|
+
{
|
|
1685
|
+
supabase: this.supabase,
|
|
1686
|
+
agent: this.context.agent
|
|
1687
|
+
}
|
|
1688
|
+
);
|
|
1689
|
+
return {
|
|
1690
|
+
content: [{
|
|
1691
|
+
type: "text",
|
|
1692
|
+
text: JSON.stringify(result)
|
|
1693
|
+
}]
|
|
1694
|
+
};
|
|
1695
|
+
} catch (error) {
|
|
1696
|
+
return {
|
|
1697
|
+
content: [{
|
|
1698
|
+
type: "text",
|
|
1699
|
+
text: JSON.stringify({
|
|
1700
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
1701
|
+
})
|
|
1702
|
+
}]
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
);
|
|
1707
|
+
this.server.tool(
|
|
1708
|
+
"register_agent",
|
|
1709
|
+
"Register this agent instance with RelayRail to enable human-in-the-loop communication. Use this tool first if you do not have an API key.",
|
|
1710
|
+
{
|
|
1711
|
+
name: z.string().min(1).max(100).describe("A descriptive name for this agent instance"),
|
|
1712
|
+
user_email: z.string().email().describe("Email address of the user who will own this agent")
|
|
1713
|
+
},
|
|
1714
|
+
async (params) => {
|
|
1715
|
+
try {
|
|
1716
|
+
const result = await registerAgent(
|
|
1717
|
+
params,
|
|
1718
|
+
{
|
|
1719
|
+
supabase: this.supabase,
|
|
1720
|
+
baseUrl: this.config.baseUrl
|
|
1721
|
+
}
|
|
1722
|
+
);
|
|
1723
|
+
return {
|
|
1724
|
+
content: [{
|
|
1725
|
+
type: "text",
|
|
1726
|
+
text: JSON.stringify(result)
|
|
1727
|
+
}]
|
|
1728
|
+
};
|
|
1729
|
+
} catch (error) {
|
|
1730
|
+
return {
|
|
1731
|
+
content: [{
|
|
1732
|
+
type: "text",
|
|
1733
|
+
text: JSON.stringify({
|
|
1734
|
+
success: false,
|
|
1735
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
1736
|
+
})
|
|
1737
|
+
}]
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
);
|
|
1742
|
+
this.server.tool(
|
|
1743
|
+
"get_account_status",
|
|
1744
|
+
"Check your current account status including tier, available channels, quota usage, and helpful tips.",
|
|
1745
|
+
{},
|
|
1746
|
+
async () => {
|
|
1747
|
+
if (!this.context) {
|
|
1748
|
+
return {
|
|
1749
|
+
content: [{ type: "text", text: JSON.stringify({ error: "Not authenticated. Use register_agent first to get an API key." }) }]
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
try {
|
|
1753
|
+
const result = await getAccountStatus({
|
|
1754
|
+
supabase: this.supabase,
|
|
1755
|
+
agent: this.context.agent,
|
|
1756
|
+
user: this.context.user
|
|
1757
|
+
});
|
|
1758
|
+
return {
|
|
1759
|
+
content: [{
|
|
1760
|
+
type: "text",
|
|
1761
|
+
text: JSON.stringify(result)
|
|
1762
|
+
}]
|
|
1763
|
+
};
|
|
1764
|
+
} catch (error) {
|
|
1765
|
+
return {
|
|
1766
|
+
content: [{
|
|
1767
|
+
type: "text",
|
|
1768
|
+
text: JSON.stringify({
|
|
1769
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
1770
|
+
})
|
|
1771
|
+
}]
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
/**
|
|
1778
|
+
* Authenticate with an API key and set the context
|
|
1779
|
+
*/
|
|
1780
|
+
async authenticate(apiKey) {
|
|
1781
|
+
const result = await authenticateApiKey(this.supabase, apiKey);
|
|
1782
|
+
if (result.success && result.agent && result.user) {
|
|
1783
|
+
this.context = {
|
|
1784
|
+
agent: result.agent,
|
|
1785
|
+
user: result.user
|
|
1786
|
+
};
|
|
1787
|
+
return true;
|
|
1788
|
+
}
|
|
1789
|
+
return false;
|
|
1790
|
+
}
|
|
1791
|
+
/**
|
|
1792
|
+
* Get the current authentication context
|
|
1793
|
+
*/
|
|
1794
|
+
getContext() {
|
|
1795
|
+
return this.context;
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* Get the underlying MCP server instance
|
|
1799
|
+
*/
|
|
1800
|
+
getMcpServer() {
|
|
1801
|
+
return this.server;
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* Get the Supabase client
|
|
1805
|
+
*/
|
|
1806
|
+
getSupabase() {
|
|
1807
|
+
return this.supabase;
|
|
1808
|
+
}
|
|
1809
|
+
/**
|
|
1810
|
+
* Get the server configuration
|
|
1811
|
+
*/
|
|
1812
|
+
getConfig() {
|
|
1813
|
+
return this.config;
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Start the server with stdio transport
|
|
1817
|
+
*/
|
|
1818
|
+
async start() {
|
|
1819
|
+
const transport = new StdioServerTransport();
|
|
1820
|
+
await this.server.connect(transport);
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
function createServer(config) {
|
|
1824
|
+
return new RelayRailServer(config);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// src/transports/http.ts
|
|
1828
|
+
var MCP_ERRORS = {
|
|
1829
|
+
PARSE_ERROR: -32700,
|
|
1830
|
+
INVALID_REQUEST: -32600,
|
|
1831
|
+
METHOD_NOT_FOUND: -32601,
|
|
1832
|
+
INVALID_PARAMS: -32602,
|
|
1833
|
+
INTERNAL_ERROR: -32603,
|
|
1834
|
+
NOT_AUTHENTICATED: -32e3,
|
|
1835
|
+
RATE_LIMITED: -32001
|
|
1836
|
+
};
|
|
1837
|
+
var HTTPTransport = class {
|
|
1838
|
+
supabase;
|
|
1839
|
+
config;
|
|
1840
|
+
router = null;
|
|
1841
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1842
|
+
sessionTTL = 24 * 60 * 60 * 1e3;
|
|
1843
|
+
// 24 hours
|
|
1844
|
+
constructor(transportConfig) {
|
|
1845
|
+
this.supabase = transportConfig.supabase;
|
|
1846
|
+
this.config = transportConfig.serverConfig;
|
|
1847
|
+
this.router = transportConfig.router ?? null;
|
|
1848
|
+
if (!this.router) {
|
|
1849
|
+
const hasEmail = !!this.config.resendApiKey;
|
|
1850
|
+
const hasSms = !!(this.config.telnyxApiKey && this.config.telnyxPhoneNumber);
|
|
1851
|
+
if (hasEmail || hasSms) {
|
|
1852
|
+
this.router = createRouter(
|
|
1853
|
+
{
|
|
1854
|
+
baseUrl: this.config.baseUrl,
|
|
1855
|
+
email: {
|
|
1856
|
+
resendApiKey: this.config.resendApiKey || "",
|
|
1857
|
+
fromEmail: "notifications@mail.relayrail.dev",
|
|
1858
|
+
fromName: "RelayRail",
|
|
1859
|
+
baseUrl: this.config.baseUrl,
|
|
1860
|
+
inboundDomain: "in.relayrail.dev"
|
|
1861
|
+
},
|
|
1862
|
+
sms: hasSms ? {
|
|
1863
|
+
apiKey: this.config.telnyxApiKey,
|
|
1864
|
+
fromNumber: this.config.telnyxPhoneNumber,
|
|
1865
|
+
baseUrl: this.config.baseUrl
|
|
1866
|
+
} : void 0
|
|
1867
|
+
},
|
|
1868
|
+
this.supabase
|
|
1869
|
+
);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
setInterval(() => this.cleanupSessions(), 60 * 60 * 1e3);
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* Handle an incoming HTTP request
|
|
1876
|
+
*/
|
|
1877
|
+
async handleRequest(body, apiKey, sessionId) {
|
|
1878
|
+
if (Array.isArray(body)) {
|
|
1879
|
+
const responses = await Promise.all(
|
|
1880
|
+
body.map((req) => this.handleSingleRequest(req, apiKey, sessionId))
|
|
1881
|
+
);
|
|
1882
|
+
return {
|
|
1883
|
+
response: responses.map((r) => r.response),
|
|
1884
|
+
newSessionId: responses[0]?.newSessionId
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
return this.handleSingleRequest(body, apiKey, sessionId);
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1890
|
+
* Handle a single MCP request
|
|
1891
|
+
*/
|
|
1892
|
+
async handleSingleRequest(request, apiKey, sessionId) {
|
|
1893
|
+
const { id, method, params } = request;
|
|
1894
|
+
if (request.jsonrpc !== "2.0") {
|
|
1895
|
+
return {
|
|
1896
|
+
response: this.errorResponse(id, MCP_ERRORS.INVALID_REQUEST, "Invalid JSON-RPC version")
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
switch (method) {
|
|
1900
|
+
case "initialize":
|
|
1901
|
+
return { response: this.handleInitialize(id) };
|
|
1902
|
+
case "tools/list":
|
|
1903
|
+
return { response: this.handleListTools(id) };
|
|
1904
|
+
case "tools/call":
|
|
1905
|
+
return this.handleToolCall(id, params, apiKey, sessionId);
|
|
1906
|
+
case "ping":
|
|
1907
|
+
return { response: this.successResponse(id, { pong: true }) };
|
|
1908
|
+
default:
|
|
1909
|
+
return {
|
|
1910
|
+
response: this.errorResponse(id, MCP_ERRORS.METHOD_NOT_FOUND, `Unknown method: ${method}`)
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* Handle initialize request
|
|
1916
|
+
*/
|
|
1917
|
+
handleInitialize(id) {
|
|
1918
|
+
return this.successResponse(id, {
|
|
1919
|
+
protocolVersion: "2024-11-05",
|
|
1920
|
+
capabilities: {
|
|
1921
|
+
tools: {}
|
|
1922
|
+
},
|
|
1923
|
+
serverInfo: {
|
|
1924
|
+
name: "relayrail",
|
|
1925
|
+
version: VERSION
|
|
1926
|
+
}
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
/**
|
|
1930
|
+
* Handle tools/list request
|
|
1931
|
+
*/
|
|
1932
|
+
handleListTools(id) {
|
|
1933
|
+
const tools = [
|
|
1934
|
+
{
|
|
1935
|
+
name: "register_agent",
|
|
1936
|
+
description: "Register this agent instance with RelayRail to enable human-in-the-loop communication. Use this tool first if you do not have an API key.",
|
|
1937
|
+
inputSchema: {
|
|
1938
|
+
type: "object",
|
|
1939
|
+
properties: {
|
|
1940
|
+
name: {
|
|
1941
|
+
type: "string",
|
|
1942
|
+
description: "A descriptive name for this agent instance"
|
|
1943
|
+
},
|
|
1944
|
+
user_email: {
|
|
1945
|
+
type: "string",
|
|
1946
|
+
description: "Email address of the user who will own this agent"
|
|
1947
|
+
}
|
|
1948
|
+
},
|
|
1949
|
+
required: ["name", "user_email"]
|
|
1950
|
+
}
|
|
1951
|
+
},
|
|
1952
|
+
{
|
|
1953
|
+
name: "request_approval",
|
|
1954
|
+
description: "Request explicit approval from the user before proceeding with an action. The user will receive a notification and can approve or reject.",
|
|
1955
|
+
inputSchema: {
|
|
1956
|
+
type: "object",
|
|
1957
|
+
properties: {
|
|
1958
|
+
message: {
|
|
1959
|
+
type: "string",
|
|
1960
|
+
description: "The message explaining what approval is being requested"
|
|
1961
|
+
},
|
|
1962
|
+
options: {
|
|
1963
|
+
type: "array",
|
|
1964
|
+
items: { type: "string" },
|
|
1965
|
+
description: "Optional list of response options for the user"
|
|
1966
|
+
},
|
|
1967
|
+
context: {
|
|
1968
|
+
type: "object",
|
|
1969
|
+
description: "Additional context data to include"
|
|
1970
|
+
},
|
|
1971
|
+
timeout_minutes: {
|
|
1972
|
+
type: "number",
|
|
1973
|
+
description: "Timeout in minutes (default: 60, max: 1440)"
|
|
1974
|
+
},
|
|
1975
|
+
severity: {
|
|
1976
|
+
type: "string",
|
|
1977
|
+
enum: ["info", "warning", "critical"],
|
|
1978
|
+
description: "Severity level of the request"
|
|
1979
|
+
}
|
|
1980
|
+
},
|
|
1981
|
+
required: ["message"]
|
|
1982
|
+
}
|
|
1983
|
+
},
|
|
1984
|
+
{
|
|
1985
|
+
name: "send_notification",
|
|
1986
|
+
description: "Send a one-way notification to the user. No response is expected.",
|
|
1987
|
+
inputSchema: {
|
|
1988
|
+
type: "object",
|
|
1989
|
+
properties: {
|
|
1990
|
+
message: {
|
|
1991
|
+
type: "string",
|
|
1992
|
+
description: "The notification message to send"
|
|
1993
|
+
},
|
|
1994
|
+
context: {
|
|
1995
|
+
type: "object",
|
|
1996
|
+
description: "Additional context data to include"
|
|
1997
|
+
},
|
|
1998
|
+
severity: {
|
|
1999
|
+
type: "string",
|
|
2000
|
+
enum: ["info", "warning", "critical"],
|
|
2001
|
+
description: "Severity level"
|
|
2002
|
+
}
|
|
2003
|
+
},
|
|
2004
|
+
required: ["message"]
|
|
2005
|
+
}
|
|
2006
|
+
},
|
|
2007
|
+
{
|
|
2008
|
+
name: "await_response",
|
|
2009
|
+
description: "Wait for a user response to a previously sent approval request.",
|
|
2010
|
+
inputSchema: {
|
|
2011
|
+
type: "object",
|
|
2012
|
+
properties: {
|
|
2013
|
+
request_id: {
|
|
2014
|
+
type: "string",
|
|
2015
|
+
description: "The request ID to wait for"
|
|
2016
|
+
},
|
|
2017
|
+
timeout_seconds: {
|
|
2018
|
+
type: "number",
|
|
2019
|
+
description: "How long to wait for a response (default: 30, max: 300)"
|
|
2020
|
+
}
|
|
2021
|
+
},
|
|
2022
|
+
required: ["request_id"]
|
|
2023
|
+
}
|
|
2024
|
+
},
|
|
2025
|
+
{
|
|
2026
|
+
name: "get_pending_commands",
|
|
2027
|
+
description: "Retrieve pending commands sent by the user via SMS or email.",
|
|
2028
|
+
inputSchema: {
|
|
2029
|
+
type: "object",
|
|
2030
|
+
properties: {
|
|
2031
|
+
limit: {
|
|
2032
|
+
type: "number",
|
|
2033
|
+
description: "Maximum number of commands to return (default: 10)"
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
];
|
|
2039
|
+
return this.successResponse(id, { tools });
|
|
2040
|
+
}
|
|
2041
|
+
/**
|
|
2042
|
+
* Handle tools/call request
|
|
2043
|
+
*/
|
|
2044
|
+
async handleToolCall(id, params, apiKey, sessionId) {
|
|
2045
|
+
const { name, arguments: args } = params;
|
|
2046
|
+
if (name === "register_agent") {
|
|
2047
|
+
try {
|
|
2048
|
+
const result = await registerAgent(
|
|
2049
|
+
args,
|
|
2050
|
+
{
|
|
2051
|
+
supabase: this.supabase,
|
|
2052
|
+
baseUrl: this.config.baseUrl
|
|
2053
|
+
}
|
|
2054
|
+
);
|
|
2055
|
+
return { response: this.successResponse(id, { content: [{ type: "text", text: JSON.stringify(result) }] }) };
|
|
2056
|
+
} catch (error) {
|
|
2057
|
+
return {
|
|
2058
|
+
response: this.successResponse(id, {
|
|
2059
|
+
content: [
|
|
2060
|
+
{
|
|
2061
|
+
type: "text",
|
|
2062
|
+
text: JSON.stringify({
|
|
2063
|
+
success: false,
|
|
2064
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
2065
|
+
})
|
|
2066
|
+
}
|
|
2067
|
+
]
|
|
2068
|
+
})
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
let context = null;
|
|
2073
|
+
let newSessionId;
|
|
2074
|
+
if (sessionId) {
|
|
2075
|
+
const session = this.sessions.get(sessionId);
|
|
2076
|
+
if (session && session.expiresAt > Date.now()) {
|
|
2077
|
+
context = session.context;
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
if (!context && apiKey) {
|
|
2081
|
+
const authResult = await authenticateApiKey(this.supabase, apiKey);
|
|
2082
|
+
if (authResult.success && authResult.agent && authResult.user) {
|
|
2083
|
+
context = {
|
|
2084
|
+
agent: authResult.agent,
|
|
2085
|
+
user: authResult.user
|
|
2086
|
+
};
|
|
2087
|
+
newSessionId = crypto.randomUUID();
|
|
2088
|
+
this.sessions.set(newSessionId, {
|
|
2089
|
+
context,
|
|
2090
|
+
expiresAt: Date.now() + this.sessionTTL
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
if (!context) {
|
|
2095
|
+
return {
|
|
2096
|
+
response: this.errorResponse(
|
|
2097
|
+
id,
|
|
2098
|
+
MCP_ERRORS.NOT_AUTHENTICATED,
|
|
2099
|
+
"Authentication required. Provide API key via Authorization header or use register_agent tool first."
|
|
2100
|
+
)
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
try {
|
|
2104
|
+
let result;
|
|
2105
|
+
switch (name) {
|
|
2106
|
+
case "request_approval":
|
|
2107
|
+
result = await requestApproval(args, {
|
|
2108
|
+
supabase: this.supabase,
|
|
2109
|
+
agent: context.agent,
|
|
2110
|
+
user: context.user,
|
|
2111
|
+
baseUrl: this.config.baseUrl,
|
|
2112
|
+
router: this.router ?? void 0
|
|
2113
|
+
});
|
|
2114
|
+
break;
|
|
2115
|
+
case "send_notification":
|
|
2116
|
+
result = await sendNotification(args, {
|
|
2117
|
+
supabase: this.supabase,
|
|
2118
|
+
agent: context.agent,
|
|
2119
|
+
user: context.user,
|
|
2120
|
+
router: this.router ?? void 0
|
|
2121
|
+
});
|
|
2122
|
+
break;
|
|
2123
|
+
case "await_response":
|
|
2124
|
+
result = await awaitResponse(args, {
|
|
2125
|
+
supabase: this.supabase,
|
|
2126
|
+
agent: context.agent
|
|
2127
|
+
});
|
|
2128
|
+
break;
|
|
2129
|
+
case "get_pending_commands":
|
|
2130
|
+
result = await getPendingCommands(args, {
|
|
2131
|
+
supabase: this.supabase,
|
|
2132
|
+
agent: context.agent
|
|
2133
|
+
});
|
|
2134
|
+
break;
|
|
2135
|
+
default:
|
|
2136
|
+
return {
|
|
2137
|
+
response: this.errorResponse(id, MCP_ERRORS.METHOD_NOT_FOUND, `Unknown tool: ${name}`),
|
|
2138
|
+
newSessionId
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
return {
|
|
2142
|
+
response: this.successResponse(id, {
|
|
2143
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
2144
|
+
}),
|
|
2145
|
+
newSessionId
|
|
2146
|
+
};
|
|
2147
|
+
} catch (error) {
|
|
2148
|
+
return {
|
|
2149
|
+
response: this.successResponse(id, {
|
|
2150
|
+
content: [
|
|
2151
|
+
{
|
|
2152
|
+
type: "text",
|
|
2153
|
+
text: JSON.stringify({
|
|
2154
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
2155
|
+
})
|
|
2156
|
+
}
|
|
2157
|
+
]
|
|
2158
|
+
}),
|
|
2159
|
+
newSessionId
|
|
2160
|
+
};
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* Create a success response
|
|
2165
|
+
*/
|
|
2166
|
+
successResponse(id, result) {
|
|
2167
|
+
return {
|
|
2168
|
+
jsonrpc: "2.0",
|
|
2169
|
+
id,
|
|
2170
|
+
result
|
|
2171
|
+
};
|
|
2172
|
+
}
|
|
2173
|
+
/**
|
|
2174
|
+
* Create an error response
|
|
2175
|
+
*/
|
|
2176
|
+
errorResponse(id, code, message, data) {
|
|
2177
|
+
return {
|
|
2178
|
+
jsonrpc: "2.0",
|
|
2179
|
+
id,
|
|
2180
|
+
error: {
|
|
2181
|
+
code,
|
|
2182
|
+
message,
|
|
2183
|
+
...data ? { data } : {}
|
|
2184
|
+
}
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Clean up expired sessions
|
|
2189
|
+
*/
|
|
2190
|
+
cleanupSessions() {
|
|
2191
|
+
const now = Date.now();
|
|
2192
|
+
for (const [sessionId, session] of this.sessions) {
|
|
2193
|
+
if (session.expiresAt < now) {
|
|
2194
|
+
this.sessions.delete(sessionId);
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
};
|
|
2199
|
+
function createHTTPTransport(config) {
|
|
2200
|
+
return new HTTPTransport(config);
|
|
2201
|
+
}
|
|
2202
|
+
export {
|
|
2203
|
+
HTTPTransport,
|
|
2204
|
+
MCP_ERRORS,
|
|
2205
|
+
RelayRailServer,
|
|
2206
|
+
VERSION,
|
|
2207
|
+
authenticateApiKey,
|
|
2208
|
+
createHTTPTransport,
|
|
2209
|
+
createServer,
|
|
2210
|
+
generateApiKey,
|
|
2211
|
+
getApiKeyPrefix,
|
|
2212
|
+
hashApiKey
|
|
2213
|
+
};
|