ranla-mcp 0.6.54 → 0.7.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/dist/index.js +1704 -0
- package/package.json +5 -4
- package/dist/cli.js +0 -45
- /package/dist/{cli.d.ts → index.d.ts} +0 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1704 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
var DEFAULT_HTTP_PORT = 3e3;
|
|
5
|
+
var DEFAULT_HTTP_HOST = "127.0.0.1";
|
|
6
|
+
function parseCliArgs(argv) {
|
|
7
|
+
const args = argv.slice(2);
|
|
8
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
9
|
+
return { mode: "help" };
|
|
10
|
+
}
|
|
11
|
+
const http = args.includes("--http");
|
|
12
|
+
if (!http) {
|
|
13
|
+
return { mode: "stdio" };
|
|
14
|
+
}
|
|
15
|
+
let port = DEFAULT_HTTP_PORT;
|
|
16
|
+
let host = DEFAULT_HTTP_HOST;
|
|
17
|
+
for (let i = 0; i < args.length; i++) {
|
|
18
|
+
const arg = args[i];
|
|
19
|
+
if (arg === "--port" || arg === "-p") {
|
|
20
|
+
const raw = args[i + 1];
|
|
21
|
+
const parsed = Number(raw);
|
|
22
|
+
if (!raw || !Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
|
23
|
+
throw new Error(`Invalid --port value: ${raw ?? "(missing)"}`);
|
|
24
|
+
}
|
|
25
|
+
port = parsed;
|
|
26
|
+
i++;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (arg?.startsWith("--port=")) {
|
|
30
|
+
const raw = arg.slice("--port=".length);
|
|
31
|
+
const parsed = Number(raw);
|
|
32
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
|
33
|
+
throw new Error(`Invalid --port value: ${raw}`);
|
|
34
|
+
}
|
|
35
|
+
port = parsed;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (arg === "--host") {
|
|
39
|
+
const raw = args[i + 1]?.trim();
|
|
40
|
+
if (!raw) throw new Error("Missing --host value");
|
|
41
|
+
host = raw;
|
|
42
|
+
i++;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (arg?.startsWith("--host=")) {
|
|
46
|
+
const raw = arg.slice("--host=".length).trim();
|
|
47
|
+
if (!raw) throw new Error("Missing --host value");
|
|
48
|
+
host = raw;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { mode: "http", port, host };
|
|
52
|
+
}
|
|
53
|
+
function printHelp() {
|
|
54
|
+
console.error(`Usage:
|
|
55
|
+
ranla-mcp # stdio (default)
|
|
56
|
+
ranla-mcp --http [--port 3000] [--host 127.0.0.1]
|
|
57
|
+
|
|
58
|
+
Environment (RANLA_* or SUPERSENDTX_*):
|
|
59
|
+
RANLA_API_KEY / SUPERSENDTX_API_KEY API key (rnl_\u2026 or stx_\u2026), required for stdio
|
|
60
|
+
RANLA_API_URL / SUPERSENDTX_API_URL Optional mail API base (default https://api.ranla.ai)
|
|
61
|
+
RANLA_APP_URL / SUPERSENDTX_APP_URL Optional app host for the Ranla agent tools
|
|
62
|
+
|
|
63
|
+
HTTP auth:
|
|
64
|
+
Authorization: Bearer rnl_\u2026 or stx_\u2026 (API key) or MCP OAuth access token
|
|
65
|
+
`);
|
|
66
|
+
}
|
|
67
|
+
function isSuperSendTxApiKey(token) {
|
|
68
|
+
return /^(?:stx_|rnl_)[a-f0-9]{48}$/i.test(token.trim());
|
|
69
|
+
}
|
|
70
|
+
function parseBearerToken(authorization) {
|
|
71
|
+
if (!authorization) return null;
|
|
72
|
+
const match = /^Bearer\s+(\S+)/i.exec(authorization.trim());
|
|
73
|
+
return match?.[1] || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/update-check.ts
|
|
77
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
78
|
+
import { homedir } from "os";
|
|
79
|
+
import { dirname, join } from "path";
|
|
80
|
+
var UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
81
|
+
var UPDATE_CHECK_TIMEOUT_MS = 1500;
|
|
82
|
+
var CLIENT_NAME_ENV = "SUPERSENDTX_CLIENT_NAME";
|
|
83
|
+
var CLIENT_VERSION_ENV = "SUPERSENDTX_CLIENT_VERSION";
|
|
84
|
+
function parseVersion(value) {
|
|
85
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value.trim());
|
|
86
|
+
if (!match) return null;
|
|
87
|
+
return {
|
|
88
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
89
|
+
pre: match[4] ? match[4].split(".") : []
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function compareVersions(a, b) {
|
|
93
|
+
const left = parseVersion(a);
|
|
94
|
+
const right = parseVersion(b);
|
|
95
|
+
if (!left || !right) return 0;
|
|
96
|
+
for (let i = 0; i < 3; i++) {
|
|
97
|
+
if (left.core[i] !== right.core[i]) return left.core[i] - right.core[i];
|
|
98
|
+
}
|
|
99
|
+
if (!left.pre.length || !right.pre.length) return right.pre.length - left.pre.length;
|
|
100
|
+
for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
|
|
101
|
+
const l = left.pre[i];
|
|
102
|
+
const r = right.pre[i];
|
|
103
|
+
if (l === void 0) return -1;
|
|
104
|
+
if (r === void 0) return 1;
|
|
105
|
+
if (l === r) continue;
|
|
106
|
+
const ln = /^\d+$/.test(l) ? Number(l) : null;
|
|
107
|
+
const rn = /^\d+$/.test(r) ? Number(r) : null;
|
|
108
|
+
if (ln !== null && rn !== null) return ln - rn;
|
|
109
|
+
if (ln !== null) return -1;
|
|
110
|
+
if (rn !== null) return 1;
|
|
111
|
+
return l < r ? -1 : 1;
|
|
112
|
+
}
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
function isNewerVersion(latest, current) {
|
|
116
|
+
return compareVersions(latest, current) > 0;
|
|
117
|
+
}
|
|
118
|
+
function noUpdateCheckEnvVar(packageName) {
|
|
119
|
+
return `${packageName.replace(/^@/, "").toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_NO_UPDATE_CHECK`;
|
|
120
|
+
}
|
|
121
|
+
function envFlag(value) {
|
|
122
|
+
const v = value?.trim().toLowerCase();
|
|
123
|
+
return Boolean(v) && v !== "0" && v !== "false" && v !== "no";
|
|
124
|
+
}
|
|
125
|
+
function isUpdateCheckDisabled(packageNames, env = process.env) {
|
|
126
|
+
if (envFlag(env.CI) || envFlag(env.NO_UPDATE_NOTIFIER)) return true;
|
|
127
|
+
return packageNames.some((name) => envFlag(env[noUpdateCheckEnvVar(name)]));
|
|
128
|
+
}
|
|
129
|
+
function resolveClientIdentity(own, env = process.env) {
|
|
130
|
+
const name = env[CLIENT_NAME_ENV]?.trim();
|
|
131
|
+
const version2 = env[CLIENT_VERSION_ENV]?.trim();
|
|
132
|
+
if (name && version2 && parseVersion(version2)) return { name, version: version2 };
|
|
133
|
+
return own;
|
|
134
|
+
}
|
|
135
|
+
function buildUserAgent(own, env = process.env) {
|
|
136
|
+
const client = resolveClientIdentity(own, env);
|
|
137
|
+
const parts = [`${client.name}/${client.version}`];
|
|
138
|
+
if (client.name !== own.name) parts.push(`${own.name}/${own.version}`);
|
|
139
|
+
parts.push(`node/${process.versions.node}`);
|
|
140
|
+
return parts.join(" ");
|
|
141
|
+
}
|
|
142
|
+
function withUserAgent(init, userAgent) {
|
|
143
|
+
const headers = init?.headers;
|
|
144
|
+
if (headers instanceof Headers) {
|
|
145
|
+
const next = new Headers(headers);
|
|
146
|
+
if (!next.has("user-agent")) next.set("User-Agent", userAgent);
|
|
147
|
+
return { ...init, headers: next };
|
|
148
|
+
}
|
|
149
|
+
if (Array.isArray(headers)) {
|
|
150
|
+
const has2 = headers.some(([key]) => key.toLowerCase() === "user-agent");
|
|
151
|
+
return { ...init, headers: has2 ? headers : [...headers, ["User-Agent", userAgent]] };
|
|
152
|
+
}
|
|
153
|
+
const record = headers ?? {};
|
|
154
|
+
const has = Object.keys(record).some((key) => key.toLowerCase() === "user-agent");
|
|
155
|
+
return { ...init, headers: has ? record : { ...record, "User-Agent": userAgent } };
|
|
156
|
+
}
|
|
157
|
+
function fetchWithUserAgent(userAgent) {
|
|
158
|
+
return ((input, init) => globalThis.fetch(input, withUserAgent(init, userAgent)));
|
|
159
|
+
}
|
|
160
|
+
function defaultUpdateCheckFile(packageName, env = process.env) {
|
|
161
|
+
const base = env.XDG_CONFIG_HOME?.trim() || (process.platform === "win32" ? env.APPDATA?.trim() || join(homedir(), "AppData", "Roaming") : "") || join(homedir(), ".config");
|
|
162
|
+
return join(base, packageName.replace(/^@/, "").replace(/\//g, "-"), "update-check.json");
|
|
163
|
+
}
|
|
164
|
+
function readCache(file) {
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
167
|
+
if (typeof parsed.checkedAt !== "number") return null;
|
|
168
|
+
return { checkedAt: parsed.checkedAt, latest: typeof parsed.latest === "string" ? parsed.latest : void 0 };
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function writeCache(file, cache) {
|
|
174
|
+
try {
|
|
175
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
176
|
+
writeFileSync(file, JSON.stringify(cache));
|
|
177
|
+
} catch {
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function fetchLatestVersion(packageName, options = {}) {
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? UPDATE_CHECK_TIMEOUT_MS);
|
|
183
|
+
timer.unref?.();
|
|
184
|
+
try {
|
|
185
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
186
|
+
const response = await doFetch(`https://registry.npmjs.org/${packageName.replace("/", "%2F")}/latest`, {
|
|
187
|
+
headers: { Accept: "application/json" },
|
|
188
|
+
signal: controller.signal
|
|
189
|
+
});
|
|
190
|
+
if (!response.ok) throw new Error(`registry returned ${response.status}`);
|
|
191
|
+
const body = await response.json();
|
|
192
|
+
if (typeof body.version !== "string" || !parseVersion(body.version)) throw new Error("registry returned no version");
|
|
193
|
+
return body.version;
|
|
194
|
+
} finally {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function startUpdateCheck(options) {
|
|
199
|
+
let info = null;
|
|
200
|
+
const none = { result: () => null, done: Promise.resolve(null) };
|
|
201
|
+
try {
|
|
202
|
+
const env = options.env ?? process.env;
|
|
203
|
+
const { name, version: version2 } = options;
|
|
204
|
+
if (!parseVersion(version2)) return none;
|
|
205
|
+
if (isUpdateCheckDisabled([name, ...options.alsoKnownAs ?? []], env)) return none;
|
|
206
|
+
const now = options.now ?? Date.now;
|
|
207
|
+
const file = options.cacheFile ?? defaultUpdateCheckFile(name, env);
|
|
208
|
+
const toInfo = (latest) => latest && isNewerVersion(latest, version2) ? { name, current: version2, latest } : null;
|
|
209
|
+
const cache = readCache(file);
|
|
210
|
+
info = toInfo(cache?.latest);
|
|
211
|
+
const age = cache ? now() - cache.checkedAt : Infinity;
|
|
212
|
+
if (age >= 0 && age < (options.intervalMs ?? UPDATE_CHECK_INTERVAL_MS)) {
|
|
213
|
+
return { result: () => info, done: Promise.resolve(info) };
|
|
214
|
+
}
|
|
215
|
+
const done = fetchLatestVersion(name, { fetch: options.fetch, timeoutMs: options.timeoutMs }).then(
|
|
216
|
+
(latest) => {
|
|
217
|
+
writeCache(file, { checkedAt: now(), latest });
|
|
218
|
+
info = toInfo(latest);
|
|
219
|
+
return info;
|
|
220
|
+
},
|
|
221
|
+
() => {
|
|
222
|
+
writeCache(file, { checkedAt: now(), latest: cache?.latest });
|
|
223
|
+
return info;
|
|
224
|
+
}
|
|
225
|
+
);
|
|
226
|
+
return { result: () => info, done };
|
|
227
|
+
} catch {
|
|
228
|
+
return { result: () => info, done: Promise.resolve(info) };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function formatMcpUpdateNotice(info) {
|
|
232
|
+
return `${info.name} ${info.latest} is available (you have ${info.current}). Update: use "npx -y ${info.name}@latest" in your MCP config and restart your MCP client, or npm i -g ${info.name}@latest`;
|
|
233
|
+
}
|
|
234
|
+
function formatMcpAgentUpdateNotice(info) {
|
|
235
|
+
return `Update available: ${info.name} ${info.latest} is out; this MCP server is ${info.current}. Tell the user once: change the server's command to "npx -y ${info.name}@latest" (or run npm i -g ${info.name}@latest), then restart their MCP client.`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// src/env.ts
|
|
239
|
+
var DEFAULT_API_BASE_URL = "https://api.ranla.ai";
|
|
240
|
+
var DEFAULT_APP_URL = "https://app.ranla.ai";
|
|
241
|
+
function applyRanlaMcpEnv(env = process.env) {
|
|
242
|
+
if (env[CLIENT_NAME_ENV]?.trim() === "supersendtx-mcp") return;
|
|
243
|
+
if (!env.SUPERSENDTX_API_KEY?.trim() && env.RANLA_API_KEY?.trim()) {
|
|
244
|
+
env.SUPERSENDTX_API_KEY = env.RANLA_API_KEY.trim();
|
|
245
|
+
}
|
|
246
|
+
if (!env.SUPERSENDTX_APP_URL?.trim()) {
|
|
247
|
+
env.SUPERSENDTX_APP_URL = env.RANLA_APP_URL?.trim() || DEFAULT_APP_URL;
|
|
248
|
+
}
|
|
249
|
+
if (!env.SUPERSENDTX_API_URL?.trim()) {
|
|
250
|
+
env.SUPERSENDTX_API_URL = env.RANLA_API_URL?.trim() || DEFAULT_API_BASE_URL;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/http.ts
|
|
255
|
+
import { createServer } from "http";
|
|
256
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
257
|
+
|
|
258
|
+
// src/server.ts
|
|
259
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
260
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
261
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
262
|
+
import { SuperSendTX } from "@supersend/ranla";
|
|
263
|
+
|
|
264
|
+
// package.json
|
|
265
|
+
var version = "0.7.0";
|
|
266
|
+
|
|
267
|
+
// src/annotations.ts
|
|
268
|
+
var READ = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
|
|
269
|
+
var WRITE = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
|
|
270
|
+
var DELETE = { readOnlyHint: false, destructiveHint: true, openWorldHint: false };
|
|
271
|
+
var OUT = { openWorldHint: true };
|
|
272
|
+
var FIXED_TOOL_HINTS = {
|
|
273
|
+
/** A sent email can't be unsent. */
|
|
274
|
+
send_email: { ...DELETE, ...OUT },
|
|
275
|
+
list_emails: READ,
|
|
276
|
+
get_email: READ,
|
|
277
|
+
cancel_email: DELETE,
|
|
278
|
+
list_received_emails: READ,
|
|
279
|
+
get_received_email: READ,
|
|
280
|
+
list_domains: READ,
|
|
281
|
+
get_domain: READ,
|
|
282
|
+
create_domain: WRITE,
|
|
283
|
+
/** Writes records at the customer's DNS provider and can replace ones already there. */
|
|
284
|
+
apply_domain_dns: { ...DELETE, ...OUT, title: "Apply domain DNS records" },
|
|
285
|
+
/** Looks the domain up in public DNS. */
|
|
286
|
+
verify_domain: { ...WRITE, ...OUT, idempotentHint: true },
|
|
287
|
+
list_webhooks: READ,
|
|
288
|
+
/** Events go to a URL outside Ranla from then on. */
|
|
289
|
+
create_webhook: { ...WRITE, ...OUT },
|
|
290
|
+
delete_webhook: DELETE,
|
|
291
|
+
list_suppressions: READ,
|
|
292
|
+
add_suppression: { ...WRITE, idempotentHint: true },
|
|
293
|
+
remove_suppression: DELETE,
|
|
294
|
+
list_templates: READ,
|
|
295
|
+
get_template: READ,
|
|
296
|
+
create_template: WRITE,
|
|
297
|
+
publish_template: WRITE,
|
|
298
|
+
send_test_webhook_event: { ...WRITE, ...OUT },
|
|
299
|
+
get_deliverability: READ,
|
|
300
|
+
arc_list_threads: { ...READ, title: "List Ranla threads" },
|
|
301
|
+
arc_get_thread: { ...READ, title: "Read a Ranla thread" },
|
|
302
|
+
arc_ensure_thread: { ...WRITE, title: "Open a Ranla thread", idempotentHint: true },
|
|
303
|
+
/**
|
|
304
|
+
* Ranla researches the web, and a turn can delete or overwrite workspace
|
|
305
|
+
* records. What reaches people or spends stops at an approval.
|
|
306
|
+
*/
|
|
307
|
+
arc_message: { ...DELETE, ...OUT, title: "Message Ranla" },
|
|
308
|
+
/** Commands include deletes (`campaign delete`, `contact delete`); what reaches people or spends stops at an approval. */
|
|
309
|
+
arc_ranla: { ...DELETE, ...OUT, title: "Run a Ranla command" },
|
|
310
|
+
arc_list_approvals: { ...READ, title: "List pending approvals" },
|
|
311
|
+
/** Approving can send email to real people or publish, which can't be undone. */
|
|
312
|
+
arc_approve: { ...DELETE, ...OUT, title: "Approve a Ranla step" },
|
|
313
|
+
/** Rejecting discards the proposal. */
|
|
314
|
+
arc_reject: { ...DELETE, title: "Reject a Ranla step" }
|
|
315
|
+
};
|
|
316
|
+
var UPPER = /* @__PURE__ */ new Set(["ai", "aeo", "api", "dns", "id", "seo", "url"]);
|
|
317
|
+
function titleFromToolName(name) {
|
|
318
|
+
const words = name.replace(/^arc_/, "").split("_").filter(Boolean);
|
|
319
|
+
return words.map((word, index) => {
|
|
320
|
+
if (UPPER.has(word)) return word.toUpperCase();
|
|
321
|
+
return index === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word;
|
|
322
|
+
}).join(" ");
|
|
323
|
+
}
|
|
324
|
+
function mcpToolAnnotations(tool) {
|
|
325
|
+
const fixed = FIXED_TOOL_HINTS[tool.name];
|
|
326
|
+
if (fixed) {
|
|
327
|
+
const { title: title2, ...hints } = fixed;
|
|
328
|
+
return { title: title2 ?? titleFromToolName(tool.name), ...hints };
|
|
329
|
+
}
|
|
330
|
+
const title = titleFromToolName(tool.name);
|
|
331
|
+
return tool.readOnly === true ? { title, ...READ } : { title, ...WRITE, ...OUT };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// src/arc-mcp.ts
|
|
335
|
+
import { randomUUID } from "crypto";
|
|
336
|
+
|
|
337
|
+
// src/client-info.ts
|
|
338
|
+
var MCP_PACKAGE = { name: "ranla-mcp", version };
|
|
339
|
+
function mcpUserAgent(env = process.env) {
|
|
340
|
+
return buildUserAgent(MCP_PACKAGE, env);
|
|
341
|
+
}
|
|
342
|
+
function mcpServerName(env = process.env) {
|
|
343
|
+
return resolveClientIdentity(MCP_PACKAGE, env).name;
|
|
344
|
+
}
|
|
345
|
+
function startMcpUpdateCheck(env = process.env) {
|
|
346
|
+
const client = resolveClientIdentity(MCP_PACKAGE, env);
|
|
347
|
+
const check = startUpdateCheck({ ...client, alsoKnownAs: [MCP_PACKAGE.name], env });
|
|
348
|
+
let logged = false;
|
|
349
|
+
const log = (info) => {
|
|
350
|
+
if (!info || logged) return;
|
|
351
|
+
logged = true;
|
|
352
|
+
try {
|
|
353
|
+
process.stderr.write(`${formatMcpUpdateNotice(info)}
|
|
354
|
+
`);
|
|
355
|
+
} catch {
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
log(check.result());
|
|
359
|
+
void check.done.then(log, () => {
|
|
360
|
+
});
|
|
361
|
+
return check;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// src/arc-mcp.ts
|
|
365
|
+
var DEFAULT_APP_ORIGIN = "https://app.supersendtx.com";
|
|
366
|
+
var DEFAULT_RANLA_APP_ORIGIN = "https://app.ranla.ai";
|
|
367
|
+
var MCP_THREAD_TITLE = "MCP";
|
|
368
|
+
function openObjectSchema(description) {
|
|
369
|
+
return {
|
|
370
|
+
type: "object",
|
|
371
|
+
properties: {},
|
|
372
|
+
additionalProperties: true,
|
|
373
|
+
...description ? { description } : {}
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
var RANLA_AGENT_NAME = "Ranla";
|
|
377
|
+
var RANLA_COMMAND_TOOL_NAME = "arc_ranla";
|
|
378
|
+
var RANLA_COMMAND_INTRO = `Run one of ${RANLA_AGENT_NAME}'s commands in this account. command is "<noun> <verb>", such as "campaign list" or "page propose", and input holds its fields. "help <noun>" lists a noun's commands with every field. A command that needs sign-off does nothing yet: it comes back with pendingApproval, and arc_approve with its messageId applies it. For strategy, or anything that needs ${RANLA_AGENT_NAME}'s judgement or research, use arc_message.`;
|
|
379
|
+
function ranlaCommandTool(index) {
|
|
380
|
+
return {
|
|
381
|
+
name: RANLA_COMMAND_TOOL_NAME,
|
|
382
|
+
description: index ? `${RANLA_COMMAND_INTRO}
|
|
383
|
+
|
|
384
|
+
Commands:
|
|
385
|
+
${index}` : RANLA_COMMAND_INTRO,
|
|
386
|
+
inputSchema: {
|
|
387
|
+
type: "object",
|
|
388
|
+
properties: {
|
|
389
|
+
command: { type: "string", description: '"<noun> <verb>", or "help <noun>".' },
|
|
390
|
+
input: { type: "object", description: "The command's fields.", additionalProperties: true },
|
|
391
|
+
threadId: { type: "string", description: "Ranla thread to record it in (from arc_ensure_thread). Optional." }
|
|
392
|
+
},
|
|
393
|
+
required: ["command"]
|
|
394
|
+
},
|
|
395
|
+
kind: "meta"
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
var META_TOOLS = [
|
|
399
|
+
{
|
|
400
|
+
name: "arc_list_threads",
|
|
401
|
+
description: "List Ranla chat threads for this account, most recent first: id, title, desk, last activity and a one-line preview. Pass nextBefore as before for older threads. Read one with arc_get_thread.",
|
|
402
|
+
inputSchema: {
|
|
403
|
+
type: "object",
|
|
404
|
+
properties: {
|
|
405
|
+
limit: { type: "integer", description: "Threads per page. Default 50, max 100." },
|
|
406
|
+
before: {
|
|
407
|
+
type: "string",
|
|
408
|
+
description: "ISO timestamp from nextBefore: only threads last active before it."
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
kind: "meta"
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
name: "arc_get_thread",
|
|
416
|
+
description: "Read a Ranla thread: its messages (role, text, tools used, artifacts by id, approvals waiting on arc_approve), newest page first. Pass earlierBefore as before for older messages.",
|
|
417
|
+
inputSchema: {
|
|
418
|
+
type: "object",
|
|
419
|
+
properties: {
|
|
420
|
+
threadId: { type: "string", description: "From arc_list_threads or arc_ensure_thread." },
|
|
421
|
+
before: {
|
|
422
|
+
type: "string",
|
|
423
|
+
description: "ISO timestamp from earlierBefore: the page of messages older than it."
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
required: ["threadId"]
|
|
427
|
+
},
|
|
428
|
+
kind: "meta"
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
name: "arc_ensure_thread",
|
|
432
|
+
description: "Find or create a durable Ranla thread for MCP (default title \u201CMCP\u201D). Returns { threadId, title }.",
|
|
433
|
+
inputSchema: {
|
|
434
|
+
type: "object",
|
|
435
|
+
properties: {
|
|
436
|
+
title: { type: "string", description: `Thread title (default ${MCP_THREAD_TITLE})` },
|
|
437
|
+
threadId: { type: "string", description: "Reuse this thread id if it still exists" }
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
kind: "meta"
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
name: "arc_message",
|
|
444
|
+
description: "Send a message to Ranla and wait for the turn. Use for strategy and anything that changes the account. Returns text, artifacts (drafts, plans, and documents it made or opened, by id), and pendingApproval when a step needs approval. Long research turns are followed to the end; one still going after 15 minutes comes back with running: true, and arc_get_thread reads the answer later.",
|
|
445
|
+
inputSchema: {
|
|
446
|
+
type: "object",
|
|
447
|
+
properties: {
|
|
448
|
+
text: { type: "string", description: `Message for ${RANLA_AGENT_NAME}` },
|
|
449
|
+
threadId: { type: "string", description: "Ranla thread id (from arc_ensure_thread)" }
|
|
450
|
+
},
|
|
451
|
+
required: ["text"]
|
|
452
|
+
},
|
|
453
|
+
kind: "meta"
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
name: "arc_list_approvals",
|
|
457
|
+
description: "List pending Ranla approvals across threads.",
|
|
458
|
+
inputSchema: openObjectSchema(),
|
|
459
|
+
kind: "meta"
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
name: "arc_approve",
|
|
463
|
+
description: "Approve a pending Ranla approval and resume the run.",
|
|
464
|
+
inputSchema: {
|
|
465
|
+
type: "object",
|
|
466
|
+
properties: {
|
|
467
|
+
messageId: { type: "string" }
|
|
468
|
+
},
|
|
469
|
+
required: ["messageId"]
|
|
470
|
+
},
|
|
471
|
+
kind: "meta"
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
name: "arc_reject",
|
|
475
|
+
description: "Reject a pending Ranla approval and resume the run.",
|
|
476
|
+
inputSchema: {
|
|
477
|
+
type: "object",
|
|
478
|
+
properties: {
|
|
479
|
+
messageId: { type: "string" }
|
|
480
|
+
},
|
|
481
|
+
required: ["messageId"]
|
|
482
|
+
},
|
|
483
|
+
kind: "meta"
|
|
484
|
+
}
|
|
485
|
+
];
|
|
486
|
+
var ARC_MCP_TOOLS = [...META_TOOLS, ranlaCommandTool()];
|
|
487
|
+
function resolveArcAppOrigin(options = {}) {
|
|
488
|
+
const fromEnv = process.env.RANLA_APP_URL?.trim() || process.env.SUPERSENDTX_APP_URL?.trim() || "";
|
|
489
|
+
if (fromEnv) return fromEnv.replace(/\/$/, "");
|
|
490
|
+
const key = options.apiKey ?? process.env.SUPERSENDTX_API_KEY ?? process.env.RANLA_API_KEY ?? "";
|
|
491
|
+
if (key.startsWith("rnl_")) return DEFAULT_RANLA_APP_ORIGIN;
|
|
492
|
+
const apiUrl = (options.baseUrl ?? process.env.SUPERSENDTX_API_URL ?? process.env.RANLA_API_URL ?? "").replace(/\/$/, "");
|
|
493
|
+
if (apiUrl.includes("api.ranla.ai")) return DEFAULT_RANLA_APP_ORIGIN;
|
|
494
|
+
if (apiUrl.includes("app.ranla.ai")) return apiUrl;
|
|
495
|
+
if (apiUrl.includes("app.supersendtx.com")) return apiUrl;
|
|
496
|
+
return DEFAULT_APP_ORIGIN;
|
|
497
|
+
}
|
|
498
|
+
async function arcFetch(origin, apiKey, path, init = {}) {
|
|
499
|
+
const response = await fetch(`${origin}${path}`, {
|
|
500
|
+
...init,
|
|
501
|
+
headers: {
|
|
502
|
+
Authorization: `Bearer ${apiKey}`,
|
|
503
|
+
"Content-Type": "application/json",
|
|
504
|
+
Accept: "application/json",
|
|
505
|
+
"User-Agent": mcpUserAgent(),
|
|
506
|
+
...init.headers ?? {}
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
const body = await response.json().catch(() => ({}));
|
|
510
|
+
return { ok: response.ok, status: response.status, body };
|
|
511
|
+
}
|
|
512
|
+
var ARC_TOOL_PREFIX = "arc_";
|
|
513
|
+
var CATALOG_TIMEOUT_MS = 4e3;
|
|
514
|
+
function isArcMcpToolName(name) {
|
|
515
|
+
return name.startsWith(ARC_TOOL_PREFIX);
|
|
516
|
+
}
|
|
517
|
+
async function fetchCommandIndex(apiKey, baseUrl) {
|
|
518
|
+
try {
|
|
519
|
+
const origin = resolveArcAppOrigin({ baseUrl, apiKey });
|
|
520
|
+
const response = await arcFetch(origin, apiKey, "/api/growth/commands", {
|
|
521
|
+
signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS)
|
|
522
|
+
});
|
|
523
|
+
const index = response.body.index;
|
|
524
|
+
return response.ok && typeof index === "string" && index.trim() ? index : null;
|
|
525
|
+
} catch {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async function loadArcMcpTools(apiKey, baseUrl) {
|
|
530
|
+
const index = await fetchCommandIndex(apiKey, baseUrl);
|
|
531
|
+
if (!index) return ARC_MCP_TOOLS;
|
|
532
|
+
return [...META_TOOLS, ranlaCommandTool(index)];
|
|
533
|
+
}
|
|
534
|
+
var ARC_MESSAGE_MAX_WAIT_MS = 15 * 6e4;
|
|
535
|
+
var ARC_MESSAGE_POLL_MS = 2e3;
|
|
536
|
+
var ARC_THREAD_POLL_MS = 5e3;
|
|
537
|
+
var ARC_MESSAGE_REQUEST_TIMEOUT_MS = 11e4;
|
|
538
|
+
var LOST_REQUEST_STATUSES = /* @__PURE__ */ new Set([408, 429, 502, 503, 504, 520, 521, 522, 523, 524]);
|
|
539
|
+
var STILL_RUNNING_NOTE = "Ranla is still working on this. Read the answer with arc_get_thread in a minute or two.";
|
|
540
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
541
|
+
function turnFromThread(threadId, messages, extra) {
|
|
542
|
+
const lastFounder = messages.map((message) => message.role).lastIndexOf("user");
|
|
543
|
+
const after = messages.slice(lastFounder + 1).filter((message) => message.role !== "user");
|
|
544
|
+
const text = after.map((message) => typeof message.text === "string" ? message.text.trim() : "").filter(Boolean).join("\n\n");
|
|
545
|
+
const tools = after.flatMap(
|
|
546
|
+
(message) => (message.tools ?? []).map((tool) => ({
|
|
547
|
+
toolName: tool.name ?? "",
|
|
548
|
+
label: tool.label ?? "",
|
|
549
|
+
...tool.isError ? { isError: true } : {}
|
|
550
|
+
}))
|
|
551
|
+
);
|
|
552
|
+
const artifacts = after.filter((message) => message.artifact?.type && message.artifact.id).map((message) => ({ type: message.artifact.type, id: message.artifact.id }));
|
|
553
|
+
const pendingApprovals = after.filter((message) => message.id && message.approval?.status === "pending").map((message) => ({
|
|
554
|
+
messageId: message.id,
|
|
555
|
+
summary: message.approval.summary ?? "",
|
|
556
|
+
...message.approval.detail ? { detail: message.approval.detail } : {}
|
|
557
|
+
}));
|
|
558
|
+
return {
|
|
559
|
+
threadId,
|
|
560
|
+
text,
|
|
561
|
+
tools,
|
|
562
|
+
...artifacts.length > 0 ? { artifacts } : {},
|
|
563
|
+
...pendingApprovals.length > 0 ? { pendingApproval: pendingApprovals.at(-1) } : {},
|
|
564
|
+
...pendingApprovals.length > 1 ? { pendingApprovals } : {},
|
|
565
|
+
...extra
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
async function followThread(origin, apiKey, threadId, deadline, extra) {
|
|
569
|
+
while (true) {
|
|
570
|
+
const response = await arcFetch(origin, apiKey, `/api/growth/threads/${encodeURIComponent(threadId)}`).catch(
|
|
571
|
+
() => null
|
|
572
|
+
);
|
|
573
|
+
const thread = response?.ok ? response.body.thread : void 0;
|
|
574
|
+
if (thread && thread.running !== true) {
|
|
575
|
+
const messages = Array.isArray(response.body.messages) ? response.body.messages : [];
|
|
576
|
+
return turnFromThread(threadId, messages, extra);
|
|
577
|
+
}
|
|
578
|
+
if (Date.now() >= deadline) {
|
|
579
|
+
return { threadId, text: "", tools: [], running: true, note: STILL_RUNNING_NOTE, ...extra };
|
|
580
|
+
}
|
|
581
|
+
await sleep(ARC_THREAD_POLL_MS);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
async function sendArcMessage(origin, apiKey, threadId, text) {
|
|
585
|
+
const path = `/api/growth/threads/${encodeURIComponent(threadId)}/messages`;
|
|
586
|
+
const idempotencyKey = randomUUID();
|
|
587
|
+
const deadline = Date.now() + ARC_MESSAGE_MAX_WAIT_MS;
|
|
588
|
+
let runId;
|
|
589
|
+
let setAsideApprovals;
|
|
590
|
+
let lost = false;
|
|
591
|
+
const extra = () => ({
|
|
592
|
+
...runId ? { runId } : {},
|
|
593
|
+
...setAsideApprovals ? { setAsideApprovals } : {}
|
|
594
|
+
});
|
|
595
|
+
while (true) {
|
|
596
|
+
const response = await arcFetch(origin, apiKey, path, {
|
|
597
|
+
method: "POST",
|
|
598
|
+
headers: { "Idempotency-Key": idempotencyKey },
|
|
599
|
+
body: JSON.stringify({ text, sync: true }),
|
|
600
|
+
signal: AbortSignal.timeout(ARC_MESSAGE_REQUEST_TIMEOUT_MS)
|
|
601
|
+
}).catch(() => null);
|
|
602
|
+
if (!response || LOST_REQUEST_STATUSES.has(response.status)) {
|
|
603
|
+
lost = true;
|
|
604
|
+
if (Date.now() >= deadline) {
|
|
605
|
+
return { threadId, text: "", tools: [], running: true, note: STILL_RUNNING_NOTE, ...extra() };
|
|
606
|
+
}
|
|
607
|
+
await sleep(ARC_MESSAGE_POLL_MS);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (response.status === 409 && lost && !runId) {
|
|
611
|
+
return followThread(origin, apiKey, threadId, deadline, extra());
|
|
612
|
+
}
|
|
613
|
+
if (!response.ok && response.status !== 500) {
|
|
614
|
+
throw new Error(
|
|
615
|
+
typeof response.body.error === "string" ? response.body.error : `Ranla message failed (${response.status})`
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
const body = response.body;
|
|
619
|
+
if (typeof body.runId === "string" && body.runId) runId = body.runId;
|
|
620
|
+
if (Array.isArray(body.setAsideApprovals) && body.setAsideApprovals.length > 0) {
|
|
621
|
+
setAsideApprovals ?? (setAsideApprovals = body.setAsideApprovals);
|
|
622
|
+
}
|
|
623
|
+
if (body.running !== true) return { threadId, ...body, ...extra() };
|
|
624
|
+
if (!runId) return followThread(origin, apiKey, threadId, deadline, extra());
|
|
625
|
+
if (Date.now() >= deadline) return { threadId, ...body, ...extra() };
|
|
626
|
+
await sleep(ARC_MESSAGE_POLL_MS);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function optString(args, key) {
|
|
630
|
+
if (args[key] == null) return void 0;
|
|
631
|
+
const value = String(args[key]).trim();
|
|
632
|
+
return value || void 0;
|
|
633
|
+
}
|
|
634
|
+
async function callArcMcpTool(apiKey, name, args, baseUrl) {
|
|
635
|
+
if (!isArcMcpToolName(name)) return null;
|
|
636
|
+
const origin = resolveArcAppOrigin({ baseUrl, apiKey });
|
|
637
|
+
if (name === RANLA_COMMAND_TOOL_NAME) {
|
|
638
|
+
const command = optString(args, "command");
|
|
639
|
+
if (!command) throw new Error("Missing required argument: command");
|
|
640
|
+
const input = args.input && typeof args.input === "object" && !Array.isArray(args.input) ? args.input : {};
|
|
641
|
+
const threadId = optString(args, "threadId");
|
|
642
|
+
const response = await arcFetch(origin, apiKey, "/api/growth/commands", {
|
|
643
|
+
method: "POST",
|
|
644
|
+
body: JSON.stringify({ command, input, ...threadId ? { threadId } : {} })
|
|
645
|
+
});
|
|
646
|
+
if (!response.ok) {
|
|
647
|
+
throw new Error(
|
|
648
|
+
typeof response.body.error === "string" ? response.body.error : `Ranla command failed (${response.status})`
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
return response.body;
|
|
652
|
+
}
|
|
653
|
+
if (!META_TOOLS.some((tool) => tool.name === name)) {
|
|
654
|
+
const growthTool = name.slice(ARC_TOOL_PREFIX.length);
|
|
655
|
+
const response = await arcFetch(origin, apiKey, `/api/growth/tools/${encodeURIComponent(growthTool)}`, {
|
|
656
|
+
method: "POST",
|
|
657
|
+
body: JSON.stringify({ input: args })
|
|
658
|
+
});
|
|
659
|
+
if (!response.ok) {
|
|
660
|
+
throw new Error(
|
|
661
|
+
typeof response.body.error === "string" ? response.body.error : `Ranla tool failed (${response.status})`
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
return response.body.result ?? response.body;
|
|
665
|
+
}
|
|
666
|
+
switch (name) {
|
|
667
|
+
case "arc_list_threads": {
|
|
668
|
+
const query = new URLSearchParams();
|
|
669
|
+
const limit = optString(args, "limit");
|
|
670
|
+
const before = optString(args, "before");
|
|
671
|
+
if (limit) query.set("limit", limit);
|
|
672
|
+
if (before) query.set("before", before);
|
|
673
|
+
const suffix = query.toString() ? `?${query.toString()}` : "";
|
|
674
|
+
const response = await arcFetch(origin, apiKey, `/api/growth/threads${suffix}`);
|
|
675
|
+
if (!response.ok) {
|
|
676
|
+
throw new Error(
|
|
677
|
+
typeof response.body.error === "string" ? response.body.error : `List threads failed (${response.status})`
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
return response.body;
|
|
681
|
+
}
|
|
682
|
+
case "arc_get_thread": {
|
|
683
|
+
const threadId = optString(args, "threadId");
|
|
684
|
+
if (!threadId) throw new Error("Missing required argument: threadId");
|
|
685
|
+
const before = optString(args, "before");
|
|
686
|
+
const response = await arcFetch(
|
|
687
|
+
origin,
|
|
688
|
+
apiKey,
|
|
689
|
+
`/api/growth/threads/${encodeURIComponent(threadId)}${before ? `?before=${encodeURIComponent(before)}` : ""}`
|
|
690
|
+
);
|
|
691
|
+
if (!response.ok) {
|
|
692
|
+
throw new Error(
|
|
693
|
+
typeof response.body.error === "string" ? response.body.error : `Get thread failed (${response.status})`
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
return response.body;
|
|
697
|
+
}
|
|
698
|
+
case "arc_ensure_thread": {
|
|
699
|
+
const existingId = optString(args, "threadId");
|
|
700
|
+
if (existingId) {
|
|
701
|
+
const byId = await arcFetch(
|
|
702
|
+
origin,
|
|
703
|
+
apiKey,
|
|
704
|
+
`/api/growth/threads/${encodeURIComponent(existingId)}`
|
|
705
|
+
);
|
|
706
|
+
if (byId.ok) {
|
|
707
|
+
const thread2 = byId.body.thread;
|
|
708
|
+
if (thread2?.id) {
|
|
709
|
+
return { threadId: thread2.id, title: thread2.title ?? null };
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const title = optString(args, "title") ?? MCP_THREAD_TITLE;
|
|
714
|
+
const byTitle = await arcFetch(
|
|
715
|
+
origin,
|
|
716
|
+
apiKey,
|
|
717
|
+
`/api/growth/threads?title=${encodeURIComponent(title)}`
|
|
718
|
+
);
|
|
719
|
+
if (byTitle.ok) {
|
|
720
|
+
const threads = Array.isArray(byTitle.body.threads) ? byTitle.body.threads : [];
|
|
721
|
+
const hit = threads.find(
|
|
722
|
+
(row) => row && typeof row === "object" && row.title === title
|
|
723
|
+
);
|
|
724
|
+
if (hit) return { threadId: hit.id, title: hit.title ?? title };
|
|
725
|
+
}
|
|
726
|
+
const created = await arcFetch(origin, apiKey, "/api/growth/threads", {
|
|
727
|
+
method: "POST",
|
|
728
|
+
body: JSON.stringify({ title })
|
|
729
|
+
});
|
|
730
|
+
if (!created.ok) {
|
|
731
|
+
throw new Error(
|
|
732
|
+
typeof created.body.error === "string" ? created.body.error : `Create thread failed (${created.status})`
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
const thread = created.body.thread;
|
|
736
|
+
if (!thread?.id) throw new Error("Create thread returned no id");
|
|
737
|
+
return { threadId: thread.id, title: thread.title ?? title };
|
|
738
|
+
}
|
|
739
|
+
case "arc_message": {
|
|
740
|
+
const text = optString(args, "text");
|
|
741
|
+
if (!text) throw new Error("Missing required argument: text");
|
|
742
|
+
let threadId = optString(args, "threadId");
|
|
743
|
+
if (!threadId) {
|
|
744
|
+
const ensured = await callArcMcpTool(apiKey, "arc_ensure_thread", {}, baseUrl);
|
|
745
|
+
threadId = ensured.threadId;
|
|
746
|
+
}
|
|
747
|
+
return sendArcMessage(origin, apiKey, threadId, text);
|
|
748
|
+
}
|
|
749
|
+
case "arc_list_approvals": {
|
|
750
|
+
const response = await arcFetch(origin, apiKey, "/api/growth/approvals");
|
|
751
|
+
if (!response.ok) {
|
|
752
|
+
throw new Error(
|
|
753
|
+
typeof response.body.error === "string" ? response.body.error : `List approvals failed (${response.status})`
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
return response.body;
|
|
757
|
+
}
|
|
758
|
+
case "arc_approve":
|
|
759
|
+
case "arc_reject": {
|
|
760
|
+
const messageId = optString(args, "messageId");
|
|
761
|
+
if (!messageId) throw new Error("Missing required argument: messageId");
|
|
762
|
+
const decision = name === "arc_approve" ? "approve" : "reject";
|
|
763
|
+
const response = await arcFetch(
|
|
764
|
+
origin,
|
|
765
|
+
apiKey,
|
|
766
|
+
`/api/growth/approvals/${encodeURIComponent(messageId)}`,
|
|
767
|
+
{
|
|
768
|
+
method: "POST",
|
|
769
|
+
body: JSON.stringify({ decision, sync: true })
|
|
770
|
+
}
|
|
771
|
+
);
|
|
772
|
+
if (!response.ok && response.status !== 500) {
|
|
773
|
+
throw new Error(
|
|
774
|
+
typeof response.body.error === "string" ? response.body.error : `Approval ${decision} failed (${response.status})`
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
return response.body;
|
|
778
|
+
}
|
|
779
|
+
default:
|
|
780
|
+
throw new Error(`Unknown Ranla MCP tool: ${name}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// src/tools.ts
|
|
785
|
+
function textResult(value, isError = false) {
|
|
786
|
+
return {
|
|
787
|
+
content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
|
|
788
|
+
...isError ? { isError: true } : {}
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
function optString2(args, key) {
|
|
792
|
+
if (args[key] == null) return void 0;
|
|
793
|
+
const value = String(args[key]).trim();
|
|
794
|
+
return value || void 0;
|
|
795
|
+
}
|
|
796
|
+
function optNumber(args, key) {
|
|
797
|
+
if (args[key] == null || args[key] === "") return void 0;
|
|
798
|
+
const n = Number(args[key]);
|
|
799
|
+
return Number.isFinite(n) ? n : void 0;
|
|
800
|
+
}
|
|
801
|
+
function optBoolean(args, key) {
|
|
802
|
+
if (args[key] == null) return void 0;
|
|
803
|
+
if (typeof args[key] === "boolean") return args[key];
|
|
804
|
+
const raw = String(args[key]).trim().toLowerCase();
|
|
805
|
+
if (raw === "true" || raw === "1") return true;
|
|
806
|
+
if (raw === "false" || raw === "0") return false;
|
|
807
|
+
return void 0;
|
|
808
|
+
}
|
|
809
|
+
function optStringArray(args, key) {
|
|
810
|
+
const value = args[key];
|
|
811
|
+
if (value == null) return void 0;
|
|
812
|
+
if (Array.isArray(value)) return value.map((v) => String(v));
|
|
813
|
+
return void 0;
|
|
814
|
+
}
|
|
815
|
+
function optRecipients(args, key) {
|
|
816
|
+
const value = args[key];
|
|
817
|
+
if (value == null) return void 0;
|
|
818
|
+
const parts = (Array.isArray(value) ? value.map((entry) => String(entry)) : String(value).split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)).map((entry) => entry.trim()).filter(Boolean);
|
|
819
|
+
if (parts.length === 0) return void 0;
|
|
820
|
+
return parts.length === 1 ? parts[0] : parts;
|
|
821
|
+
}
|
|
822
|
+
function optStringMap(args, key) {
|
|
823
|
+
const value = args[key];
|
|
824
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
825
|
+
const entries = Object.entries(value).filter(([, entry]) => entry != null);
|
|
826
|
+
return entries.length > 0 ? Object.fromEntries(entries.map(([name, entry]) => [name, String(entry)])) : void 0;
|
|
827
|
+
}
|
|
828
|
+
var TEST_WEBHOOK_EVENT_FLAGS = {
|
|
829
|
+
"email.sent": true,
|
|
830
|
+
"email.delivered": true,
|
|
831
|
+
"email.delivery_delayed": true,
|
|
832
|
+
"email.bounced": true,
|
|
833
|
+
"email.complained": true,
|
|
834
|
+
"email.opened": true,
|
|
835
|
+
"email.clicked": true,
|
|
836
|
+
"email.failed": true,
|
|
837
|
+
"email.suppressed": true,
|
|
838
|
+
"email.scheduled": true
|
|
839
|
+
};
|
|
840
|
+
var TEST_WEBHOOK_EVENTS = new Set(Object.keys(TEST_WEBHOOK_EVENT_FLAGS));
|
|
841
|
+
var WEBHOOK_EVENT_FLAGS = {
|
|
842
|
+
...TEST_WEBHOOK_EVENT_FLAGS,
|
|
843
|
+
"email.received": true,
|
|
844
|
+
"contact.unsubscribed": true,
|
|
845
|
+
"automation.started": true,
|
|
846
|
+
"automation.step_completed": true,
|
|
847
|
+
"automation.completed": true,
|
|
848
|
+
"automation.failed": true,
|
|
849
|
+
"affiliate.created": true,
|
|
850
|
+
"affiliate.approved": true,
|
|
851
|
+
"affiliate.updated": true,
|
|
852
|
+
"referral.created": true,
|
|
853
|
+
"referral.converted": true,
|
|
854
|
+
"commission.created": true,
|
|
855
|
+
"commission.earned": true,
|
|
856
|
+
"commission.reversed": true,
|
|
857
|
+
"commission.paid": true,
|
|
858
|
+
"payout.created": true,
|
|
859
|
+
"payout.paid": true,
|
|
860
|
+
"payout.failed": true
|
|
861
|
+
};
|
|
862
|
+
var WEBHOOK_EVENTS = Object.keys(WEBHOOK_EVENT_FLAGS);
|
|
863
|
+
var EMAIL_CATEGORIES = ["transactional", "product", "newsletter"];
|
|
864
|
+
async function callMcpTool(client, name, args, options = {}) {
|
|
865
|
+
try {
|
|
866
|
+
if (isArcMcpToolName(name)) {
|
|
867
|
+
const apiKey = options.apiKey ?? resolveApiKey();
|
|
868
|
+
const result = await callArcMcpTool(apiKey, name, args, options.baseUrl);
|
|
869
|
+
return textResult(result);
|
|
870
|
+
}
|
|
871
|
+
switch (name) {
|
|
872
|
+
case "send_email": {
|
|
873
|
+
const from = optString2(args, "from");
|
|
874
|
+
const to = optRecipients(args, "to");
|
|
875
|
+
const subject = optString2(args, "subject");
|
|
876
|
+
const templateId = optString2(args, "template_id") || optString2(args, "template_alias");
|
|
877
|
+
if (!from) return textResult("Missing required argument: from", true);
|
|
878
|
+
if (!to) return textResult("Missing required argument: to", true);
|
|
879
|
+
if (!templateId && !subject) {
|
|
880
|
+
return textResult("Provide subject, or template_id / template_alias", true);
|
|
881
|
+
}
|
|
882
|
+
const category = optString2(args, "category");
|
|
883
|
+
if (category && !EMAIL_CATEGORIES.includes(category)) {
|
|
884
|
+
return textResult(`Invalid category. Use one of: ${EMAIL_CATEGORIES.join(", ")}`, true);
|
|
885
|
+
}
|
|
886
|
+
const variables = args.variables && typeof args.variables === "object" && !Array.isArray(args.variables) ? args.variables : void 0;
|
|
887
|
+
const cc = optRecipients(args, "cc");
|
|
888
|
+
const bcc = optRecipients(args, "bcc");
|
|
889
|
+
const tags = optStringMap(args, "tags");
|
|
890
|
+
const headers = optStringMap(args, "headers");
|
|
891
|
+
const scheduledAt = optString2(args, "scheduled_at");
|
|
892
|
+
const idempotencyKey = optString2(args, "idempotency_key");
|
|
893
|
+
const unsubscribe = optBoolean(args, "unsubscribe");
|
|
894
|
+
const result = await client.emails.send({
|
|
895
|
+
from,
|
|
896
|
+
to,
|
|
897
|
+
...subject ? { subject } : {},
|
|
898
|
+
html: optString2(args, "html"),
|
|
899
|
+
text: optString2(args, "text"),
|
|
900
|
+
reply_to: optRecipients(args, "reply_to"),
|
|
901
|
+
...cc ? { cc } : {},
|
|
902
|
+
...bcc ? { bcc } : {},
|
|
903
|
+
...tags ? { tags } : {},
|
|
904
|
+
...headers ? { headers } : {},
|
|
905
|
+
...scheduledAt ? { scheduled_at: scheduledAt } : {},
|
|
906
|
+
...category ? { category } : {},
|
|
907
|
+
...unsubscribe !== void 0 ? { unsubscribe } : {},
|
|
908
|
+
...idempotencyKey ? { idempotencyKey } : {},
|
|
909
|
+
...templateId ? { template: { id: templateId, ...variables ? { variables } : {} } } : {}
|
|
910
|
+
});
|
|
911
|
+
return textResult(result);
|
|
912
|
+
}
|
|
913
|
+
case "list_emails": {
|
|
914
|
+
const result = await client.emails.list({
|
|
915
|
+
limit: optNumber(args, "limit"),
|
|
916
|
+
cursor: optString2(args, "cursor")
|
|
917
|
+
});
|
|
918
|
+
return textResult(result);
|
|
919
|
+
}
|
|
920
|
+
case "get_email": {
|
|
921
|
+
const id = optString2(args, "id");
|
|
922
|
+
if (!id) return textResult("Missing required argument: id", true);
|
|
923
|
+
return textResult(await client.emails.get(id));
|
|
924
|
+
}
|
|
925
|
+
case "cancel_email": {
|
|
926
|
+
const id = optString2(args, "id");
|
|
927
|
+
if (!id) return textResult("Missing required argument: id", true);
|
|
928
|
+
return textResult(await client.emails.cancel(id));
|
|
929
|
+
}
|
|
930
|
+
case "list_received_emails": {
|
|
931
|
+
return textResult(
|
|
932
|
+
await client.receivedEmails.list({
|
|
933
|
+
limit: optNumber(args, "limit"),
|
|
934
|
+
cursor: optString2(args, "cursor"),
|
|
935
|
+
domain: optString2(args, "domain")
|
|
936
|
+
})
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
case "get_received_email": {
|
|
940
|
+
const id = optString2(args, "id");
|
|
941
|
+
if (!id) return textResult("Missing required argument: id", true);
|
|
942
|
+
return textResult(await client.receivedEmails.get(id));
|
|
943
|
+
}
|
|
944
|
+
case "get_domain": {
|
|
945
|
+
const domain = optString2(args, "domain");
|
|
946
|
+
if (!domain) return textResult("Missing required argument: domain", true);
|
|
947
|
+
return textResult(await client.domains.get(domain));
|
|
948
|
+
}
|
|
949
|
+
case "apply_domain_dns": {
|
|
950
|
+
const domain = String(args.domain ?? "").trim();
|
|
951
|
+
if (!domain) {
|
|
952
|
+
return textResult("Missing required argument: domain", true);
|
|
953
|
+
}
|
|
954
|
+
const providerRaw = typeof args.provider === "string" ? args.provider.trim().toLowerCase() : "";
|
|
955
|
+
if (providerRaw !== "cloudflare" && providerRaw !== "godaddy" && providerRaw !== "vercel") {
|
|
956
|
+
return textResult(
|
|
957
|
+
'Missing or invalid provider. Use provider "cloudflare", "godaddy", or "vercel".',
|
|
958
|
+
true
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
const credentials = args.credentials && typeof args.credentials === "object" ? args.credentials : void 0;
|
|
962
|
+
const result = await client.domains.apply(domain, {
|
|
963
|
+
provider: providerRaw,
|
|
964
|
+
...credentials ? { credentials } : {}
|
|
965
|
+
});
|
|
966
|
+
return textResult(result);
|
|
967
|
+
}
|
|
968
|
+
case "verify_domain": {
|
|
969
|
+
const domain = optString2(args, "domain");
|
|
970
|
+
if (!domain) return textResult("Missing required argument: domain", true);
|
|
971
|
+
const result = await client.domains.verify(domain);
|
|
972
|
+
return textResult(result);
|
|
973
|
+
}
|
|
974
|
+
case "list_domains": {
|
|
975
|
+
const inbound = optBoolean(args, "inbound_enabled");
|
|
976
|
+
const result = await client.domains.list({
|
|
977
|
+
limit: optNumber(args, "limit"),
|
|
978
|
+
cursor: optString2(args, "cursor"),
|
|
979
|
+
...inbound !== void 0 ? { inbound_enabled: inbound } : {}
|
|
980
|
+
});
|
|
981
|
+
return textResult(result);
|
|
982
|
+
}
|
|
983
|
+
case "create_domain": {
|
|
984
|
+
const domainName = optString2(args, "name");
|
|
985
|
+
if (!domainName) return textResult("Missing required argument: name", true);
|
|
986
|
+
const inbound = optBoolean(args, "inbound_enabled");
|
|
987
|
+
return textResult(
|
|
988
|
+
await client.domains.create({
|
|
989
|
+
name: domainName,
|
|
990
|
+
...inbound !== void 0 ? { inbound_enabled: inbound } : {}
|
|
991
|
+
})
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
case "list_webhooks": {
|
|
995
|
+
return textResult(
|
|
996
|
+
await client.webhooks.list({
|
|
997
|
+
limit: optNumber(args, "limit"),
|
|
998
|
+
cursor: optString2(args, "cursor")
|
|
999
|
+
})
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
case "create_webhook": {
|
|
1003
|
+
const url = optString2(args, "url");
|
|
1004
|
+
if (!url) return textResult("Missing required argument: url", true);
|
|
1005
|
+
const events = optStringArray(args, "events");
|
|
1006
|
+
const unknown = events?.filter((event) => !WEBHOOK_EVENTS.includes(event)) ?? [];
|
|
1007
|
+
if (unknown.length > 0) {
|
|
1008
|
+
return textResult(`Unknown event types: ${unknown.join(", ")}. Use: ${WEBHOOK_EVENTS.join(", ")}`, true);
|
|
1009
|
+
}
|
|
1010
|
+
return textResult(
|
|
1011
|
+
await client.webhooks.create({
|
|
1012
|
+
url,
|
|
1013
|
+
...events ? { events } : {}
|
|
1014
|
+
})
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
case "delete_webhook": {
|
|
1018
|
+
const id = optString2(args, "id");
|
|
1019
|
+
if (!id) return textResult("Missing required argument: id", true);
|
|
1020
|
+
return textResult(await client.webhooks.delete(id));
|
|
1021
|
+
}
|
|
1022
|
+
case "list_suppressions": {
|
|
1023
|
+
return textResult(
|
|
1024
|
+
await client.suppressions.list({
|
|
1025
|
+
limit: optNumber(args, "limit"),
|
|
1026
|
+
cursor: optString2(args, "cursor"),
|
|
1027
|
+
email: optString2(args, "email")
|
|
1028
|
+
})
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
case "add_suppression": {
|
|
1032
|
+
const email = optString2(args, "email");
|
|
1033
|
+
if (!email) return textResult("Missing required argument: email", true);
|
|
1034
|
+
return textResult(
|
|
1035
|
+
await client.suppressions.create({
|
|
1036
|
+
email,
|
|
1037
|
+
reason: optString2(args, "reason")
|
|
1038
|
+
})
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
case "remove_suppression": {
|
|
1042
|
+
const idOrEmail = optString2(args, "id") ?? optString2(args, "email");
|
|
1043
|
+
if (!idOrEmail) {
|
|
1044
|
+
return textResult("Missing required argument: id or email", true);
|
|
1045
|
+
}
|
|
1046
|
+
return textResult(await client.suppressions.remove(idOrEmail));
|
|
1047
|
+
}
|
|
1048
|
+
case "list_templates": {
|
|
1049
|
+
const status = optString2(args, "status");
|
|
1050
|
+
return textResult(
|
|
1051
|
+
await client.templates.list({
|
|
1052
|
+
limit: optNumber(args, "limit"),
|
|
1053
|
+
cursor: optString2(args, "cursor"),
|
|
1054
|
+
...status === "draft" || status === "published" ? { status } : {}
|
|
1055
|
+
})
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
case "get_template": {
|
|
1059
|
+
const idOrAlias = optString2(args, "id") ?? optString2(args, "alias");
|
|
1060
|
+
if (!idOrAlias) return textResult("Missing required argument: id or alias", true);
|
|
1061
|
+
return textResult(await client.templates.get(idOrAlias));
|
|
1062
|
+
}
|
|
1063
|
+
case "create_template": {
|
|
1064
|
+
const name2 = optString2(args, "name");
|
|
1065
|
+
const subjectArg = optString2(args, "subject");
|
|
1066
|
+
if (!name2) return textResult("Missing required argument: name", true);
|
|
1067
|
+
const format = optString2(args, "format");
|
|
1068
|
+
return textResult(
|
|
1069
|
+
await client.templates.create({
|
|
1070
|
+
name: name2,
|
|
1071
|
+
// Optional: a layout-only template has no default subject.
|
|
1072
|
+
...subjectArg ? { subject: subjectArg } : {},
|
|
1073
|
+
...format === "blocks" || format === "html" ? { format } : {},
|
|
1074
|
+
html: optString2(args, "html"),
|
|
1075
|
+
text: optString2(args, "text"),
|
|
1076
|
+
alias: optString2(args, "alias")
|
|
1077
|
+
})
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
case "publish_template": {
|
|
1081
|
+
const idOrAlias = optString2(args, "id") ?? optString2(args, "alias");
|
|
1082
|
+
if (!idOrAlias) return textResult("Missing required argument: id or alias", true);
|
|
1083
|
+
const label = optString2(args, "label");
|
|
1084
|
+
return textResult(await client.templates.publish(idOrAlias, label ? { label } : {}));
|
|
1085
|
+
}
|
|
1086
|
+
case "send_test_webhook_event": {
|
|
1087
|
+
const event = optString2(args, "event");
|
|
1088
|
+
if (!event || !TEST_WEBHOOK_EVENTS.has(event)) {
|
|
1089
|
+
return textResult(
|
|
1090
|
+
`Missing or invalid event. Use one of: ${[...TEST_WEBHOOK_EVENTS].join(", ")}`,
|
|
1091
|
+
true
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
return textResult(
|
|
1095
|
+
await client.emails.testWebhook({
|
|
1096
|
+
event,
|
|
1097
|
+
email_id: optString2(args, "email_id"),
|
|
1098
|
+
webhook_id: optString2(args, "webhook_id"),
|
|
1099
|
+
deliver: optBoolean(args, "deliver")
|
|
1100
|
+
})
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
case "get_deliverability": {
|
|
1104
|
+
const window = args.window === "7d" ? "7d" : "30d";
|
|
1105
|
+
const result = await client.emails.insights(window);
|
|
1106
|
+
return textResult(result);
|
|
1107
|
+
}
|
|
1108
|
+
default:
|
|
1109
|
+
return textResult(`Unknown tool: ${name}`, true);
|
|
1110
|
+
}
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
const message = error && typeof error === "object" && "message" in error ? String(error.message) : "Request failed";
|
|
1113
|
+
return textResult(message, true);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
var paginationProps = {
|
|
1117
|
+
limit: { type: "number", description: "Page size (1\u2013100)" },
|
|
1118
|
+
cursor: { type: "string", description: "Opaque cursor for the next page" }
|
|
1119
|
+
};
|
|
1120
|
+
var recipientProp = (what) => ({
|
|
1121
|
+
description: `${what}: one address, several separated by commas, or an array of addresses`,
|
|
1122
|
+
anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }]
|
|
1123
|
+
});
|
|
1124
|
+
var MCP_TOOLS = [
|
|
1125
|
+
{
|
|
1126
|
+
name: "send_email",
|
|
1127
|
+
description: "Send an email. Provide subject + html/text, or a published template_id / template_alias with optional variables. Set scheduled_at to send later (cancel with cancel_email).",
|
|
1128
|
+
inputSchema: {
|
|
1129
|
+
type: "object",
|
|
1130
|
+
properties: {
|
|
1131
|
+
from: {
|
|
1132
|
+
type: "string",
|
|
1133
|
+
description: "Sender address on a verified domain (or `Name <address>`). Before a domain is verified, use the sandbox sender noreply@mail.ranla.ai and send only to the account email."
|
|
1134
|
+
},
|
|
1135
|
+
to: recipientProp("Recipients"),
|
|
1136
|
+
cc: recipientProp("Cc recipients"),
|
|
1137
|
+
bcc: recipientProp("Bcc recipients"),
|
|
1138
|
+
reply_to: recipientProp("Reply-To"),
|
|
1139
|
+
subject: { type: "string", description: "Optional when using a published template" },
|
|
1140
|
+
html: { type: "string" },
|
|
1141
|
+
text: { type: "string" },
|
|
1142
|
+
template_id: { type: "string", description: "Published template id" },
|
|
1143
|
+
template_alias: { type: "string", description: "Published template alias" },
|
|
1144
|
+
variables: {
|
|
1145
|
+
type: "object",
|
|
1146
|
+
description: "Template variables when using template_id or template_alias",
|
|
1147
|
+
additionalProperties: true
|
|
1148
|
+
},
|
|
1149
|
+
scheduled_at: { type: "string", description: "ISO 8601 time to send, e.g. 2026-10-01T09:00:00Z" },
|
|
1150
|
+
category: {
|
|
1151
|
+
type: "string",
|
|
1152
|
+
enum: [...EMAIL_CATEGORIES],
|
|
1153
|
+
description: "Default transactional. product and newsletter add an unsubscribe link and skip recipients who opted out of that category."
|
|
1154
|
+
},
|
|
1155
|
+
tags: {
|
|
1156
|
+
type: "object",
|
|
1157
|
+
description: "Tags as name \u2192 value, for filtering and webhooks",
|
|
1158
|
+
additionalProperties: { type: "string" }
|
|
1159
|
+
},
|
|
1160
|
+
headers: {
|
|
1161
|
+
type: "object",
|
|
1162
|
+
description: "Extra email headers as name \u2192 value",
|
|
1163
|
+
additionalProperties: { type: "string" }
|
|
1164
|
+
},
|
|
1165
|
+
idempotency_key: {
|
|
1166
|
+
type: "string",
|
|
1167
|
+
description: "Retry-safe key: the same key within 24 hours returns the first send instead of sending twice"
|
|
1168
|
+
},
|
|
1169
|
+
unsubscribe: { type: "boolean", description: "Add a managed unsubscribe link to this send" }
|
|
1170
|
+
},
|
|
1171
|
+
required: ["from", "to"]
|
|
1172
|
+
}
|
|
1173
|
+
},
|
|
1174
|
+
{
|
|
1175
|
+
name: "list_emails",
|
|
1176
|
+
description: "List recent emails sent from this account.",
|
|
1177
|
+
inputSchema: {
|
|
1178
|
+
type: "object",
|
|
1179
|
+
properties: { ...paginationProps }
|
|
1180
|
+
}
|
|
1181
|
+
},
|
|
1182
|
+
{
|
|
1183
|
+
name: "get_email",
|
|
1184
|
+
description: "Get a single email by public id (msg_\u2026).",
|
|
1185
|
+
inputSchema: {
|
|
1186
|
+
type: "object",
|
|
1187
|
+
properties: {
|
|
1188
|
+
id: { type: "string", description: "Email id (msg_\u2026)" }
|
|
1189
|
+
},
|
|
1190
|
+
required: ["id"]
|
|
1191
|
+
}
|
|
1192
|
+
},
|
|
1193
|
+
{
|
|
1194
|
+
name: "cancel_email",
|
|
1195
|
+
description: "Cancel a scheduled email before it sends.",
|
|
1196
|
+
inputSchema: {
|
|
1197
|
+
type: "object",
|
|
1198
|
+
properties: {
|
|
1199
|
+
id: { type: "string", description: "Email id (msg_\u2026) of a scheduled send" }
|
|
1200
|
+
},
|
|
1201
|
+
required: ["id"]
|
|
1202
|
+
}
|
|
1203
|
+
},
|
|
1204
|
+
{
|
|
1205
|
+
name: "list_received_emails",
|
|
1206
|
+
description: "List inbound emails received on domains with inbound enabled.",
|
|
1207
|
+
inputSchema: {
|
|
1208
|
+
type: "object",
|
|
1209
|
+
properties: {
|
|
1210
|
+
...paginationProps,
|
|
1211
|
+
domain: { type: "string", description: "Only messages received on this domain" }
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
{
|
|
1216
|
+
name: "get_received_email",
|
|
1217
|
+
description: "Get one inbound email, including its headers and body.",
|
|
1218
|
+
inputSchema: {
|
|
1219
|
+
type: "object",
|
|
1220
|
+
properties: {
|
|
1221
|
+
id: { type: "string", description: "Received email id" }
|
|
1222
|
+
},
|
|
1223
|
+
required: ["id"]
|
|
1224
|
+
}
|
|
1225
|
+
},
|
|
1226
|
+
{
|
|
1227
|
+
name: "list_domains",
|
|
1228
|
+
description: "List sending domains for this account.",
|
|
1229
|
+
inputSchema: {
|
|
1230
|
+
type: "object",
|
|
1231
|
+
properties: {
|
|
1232
|
+
...paginationProps,
|
|
1233
|
+
inbound_enabled: { type: "boolean", description: "Only domains with inbound receiving on (or off)" }
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
},
|
|
1237
|
+
{
|
|
1238
|
+
name: "get_domain",
|
|
1239
|
+
description: "Get one sending domain by id or name, with its DNS records and verification status.",
|
|
1240
|
+
inputSchema: {
|
|
1241
|
+
type: "object",
|
|
1242
|
+
properties: {
|
|
1243
|
+
domain: { type: "string", description: "Domain id or name (example.com)" }
|
|
1244
|
+
},
|
|
1245
|
+
required: ["domain"]
|
|
1246
|
+
}
|
|
1247
|
+
},
|
|
1248
|
+
{
|
|
1249
|
+
name: "create_domain",
|
|
1250
|
+
description: "Add a sending domain. DNS must still be applied and verified before production sends.",
|
|
1251
|
+
inputSchema: {
|
|
1252
|
+
type: "object",
|
|
1253
|
+
properties: {
|
|
1254
|
+
name: { type: "string", description: "Domain name (example.com)" },
|
|
1255
|
+
inbound_enabled: { type: "boolean", description: "Enable inbound receiving on this domain" }
|
|
1256
|
+
},
|
|
1257
|
+
required: ["name"]
|
|
1258
|
+
}
|
|
1259
|
+
},
|
|
1260
|
+
{
|
|
1261
|
+
name: "apply_domain_dns",
|
|
1262
|
+
description: "Create the sending domain's DNS records at your DNS provider (Cloudflare, GoDaddy, or Vercel), using the connection stored in the dashboard. GoDaddy also accepts one-time credentials on the request.",
|
|
1263
|
+
inputSchema: {
|
|
1264
|
+
type: "object",
|
|
1265
|
+
properties: {
|
|
1266
|
+
domain: { type: "string" },
|
|
1267
|
+
provider: { type: "string", enum: ["cloudflare", "godaddy", "vercel"] },
|
|
1268
|
+
credentials: {
|
|
1269
|
+
type: "object",
|
|
1270
|
+
description: "Optional inline registrar credentials (GoDaddy api key/secret)"
|
|
1271
|
+
}
|
|
1272
|
+
},
|
|
1273
|
+
required: ["domain", "provider"]
|
|
1274
|
+
}
|
|
1275
|
+
},
|
|
1276
|
+
{
|
|
1277
|
+
name: "verify_domain",
|
|
1278
|
+
description: "Verify DNS for a sending domain after records are applied.",
|
|
1279
|
+
inputSchema: {
|
|
1280
|
+
type: "object",
|
|
1281
|
+
properties: {
|
|
1282
|
+
domain: { type: "string" }
|
|
1283
|
+
},
|
|
1284
|
+
required: ["domain"]
|
|
1285
|
+
}
|
|
1286
|
+
},
|
|
1287
|
+
{
|
|
1288
|
+
name: "list_webhooks",
|
|
1289
|
+
description: "List webhook endpoints for this account.",
|
|
1290
|
+
inputSchema: {
|
|
1291
|
+
type: "object",
|
|
1292
|
+
properties: { ...paginationProps }
|
|
1293
|
+
}
|
|
1294
|
+
},
|
|
1295
|
+
{
|
|
1296
|
+
name: "create_webhook",
|
|
1297
|
+
description: "Create a webhook endpoint. Returns the endpoint plus a signing secret (shown once).",
|
|
1298
|
+
inputSchema: {
|
|
1299
|
+
type: "object",
|
|
1300
|
+
properties: {
|
|
1301
|
+
url: { type: "string", description: "HTTPS URL that receives events" },
|
|
1302
|
+
events: {
|
|
1303
|
+
type: "array",
|
|
1304
|
+
items: { type: "string", enum: WEBHOOK_EVENTS },
|
|
1305
|
+
description: "Event types to deliver (all of them when omitted)"
|
|
1306
|
+
}
|
|
1307
|
+
},
|
|
1308
|
+
required: ["url"]
|
|
1309
|
+
}
|
|
1310
|
+
},
|
|
1311
|
+
{
|
|
1312
|
+
name: "delete_webhook",
|
|
1313
|
+
description: "Delete a webhook endpoint by id.",
|
|
1314
|
+
inputSchema: {
|
|
1315
|
+
type: "object",
|
|
1316
|
+
properties: {
|
|
1317
|
+
id: { type: "string", description: "Webhook id" }
|
|
1318
|
+
},
|
|
1319
|
+
required: ["id"]
|
|
1320
|
+
}
|
|
1321
|
+
},
|
|
1322
|
+
{
|
|
1323
|
+
name: "list_suppressions",
|
|
1324
|
+
description: "List suppressed recipient addresses.",
|
|
1325
|
+
inputSchema: {
|
|
1326
|
+
type: "object",
|
|
1327
|
+
properties: {
|
|
1328
|
+
...paginationProps,
|
|
1329
|
+
email: { type: "string", description: "Optional filter by email" }
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
},
|
|
1333
|
+
{
|
|
1334
|
+
name: "add_suppression",
|
|
1335
|
+
description: "Add an address to the suppression list.",
|
|
1336
|
+
inputSchema: {
|
|
1337
|
+
type: "object",
|
|
1338
|
+
properties: {
|
|
1339
|
+
email: { type: "string" },
|
|
1340
|
+
reason: { type: "string" }
|
|
1341
|
+
},
|
|
1342
|
+
required: ["email"]
|
|
1343
|
+
}
|
|
1344
|
+
},
|
|
1345
|
+
{
|
|
1346
|
+
name: "remove_suppression",
|
|
1347
|
+
description: "Remove a suppression by id or email address.",
|
|
1348
|
+
inputSchema: {
|
|
1349
|
+
type: "object",
|
|
1350
|
+
properties: {
|
|
1351
|
+
id: { type: "string", description: "Suppression id" },
|
|
1352
|
+
email: { type: "string", description: "Email address (alternative to id)" }
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
},
|
|
1356
|
+
{
|
|
1357
|
+
name: "list_templates",
|
|
1358
|
+
description: "List email templates.",
|
|
1359
|
+
inputSchema: {
|
|
1360
|
+
type: "object",
|
|
1361
|
+
properties: {
|
|
1362
|
+
...paginationProps,
|
|
1363
|
+
status: { type: "string", enum: ["draft", "published"] }
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
},
|
|
1367
|
+
{
|
|
1368
|
+
name: "get_template",
|
|
1369
|
+
description: "Get a template by id or alias.",
|
|
1370
|
+
inputSchema: {
|
|
1371
|
+
type: "object",
|
|
1372
|
+
properties: {
|
|
1373
|
+
id: { type: "string", description: "Template id" },
|
|
1374
|
+
alias: { type: "string", description: "Published alias (alternative to id)" }
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
},
|
|
1378
|
+
{
|
|
1379
|
+
name: "create_template",
|
|
1380
|
+
description: "Create a draft email template (html or blocks format). Publish it before sending with it.",
|
|
1381
|
+
inputSchema: {
|
|
1382
|
+
type: "object",
|
|
1383
|
+
properties: {
|
|
1384
|
+
name: { type: "string" },
|
|
1385
|
+
subject: { type: "string" },
|
|
1386
|
+
format: { type: "string", enum: ["html", "blocks"] },
|
|
1387
|
+
html: { type: "string" },
|
|
1388
|
+
text: { type: "string" },
|
|
1389
|
+
alias: { type: "string" }
|
|
1390
|
+
},
|
|
1391
|
+
required: ["name"]
|
|
1392
|
+
}
|
|
1393
|
+
},
|
|
1394
|
+
{
|
|
1395
|
+
name: "publish_template",
|
|
1396
|
+
description: "Publish a template, snapshotting an immutable version for sends.",
|
|
1397
|
+
inputSchema: {
|
|
1398
|
+
type: "object",
|
|
1399
|
+
properties: {
|
|
1400
|
+
id: { type: "string" },
|
|
1401
|
+
alias: { type: "string" },
|
|
1402
|
+
label: { type: "string", description: "Optional name for this version in the template history" }
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
},
|
|
1406
|
+
{
|
|
1407
|
+
name: "send_test_webhook_event",
|
|
1408
|
+
description: "Simulate a webhook event (POST /emails/test) for CI or local webhook testing. Optionally deliver to subscribed endpoints.",
|
|
1409
|
+
inputSchema: {
|
|
1410
|
+
type: "object",
|
|
1411
|
+
properties: {
|
|
1412
|
+
event: {
|
|
1413
|
+
type: "string",
|
|
1414
|
+
enum: [...TEST_WEBHOOK_EVENTS],
|
|
1415
|
+
description: "Email event to simulate (email.received cannot be simulated \u2014 send a real inbound message)"
|
|
1416
|
+
},
|
|
1417
|
+
email_id: { type: "string", description: "Optional existing email id (msg_\u2026)" },
|
|
1418
|
+
webhook_id: { type: "string", description: "Deliver to this one webhook endpoint only" },
|
|
1419
|
+
deliver: {
|
|
1420
|
+
type: "boolean",
|
|
1421
|
+
description: "When true (default), enqueue delivery to account webhooks"
|
|
1422
|
+
}
|
|
1423
|
+
},
|
|
1424
|
+
required: ["event"]
|
|
1425
|
+
}
|
|
1426
|
+
},
|
|
1427
|
+
{
|
|
1428
|
+
name: "get_deliverability",
|
|
1429
|
+
description: "Fetch best-effort deliverability metrics for recent sends (7d or 30d window).",
|
|
1430
|
+
inputSchema: {
|
|
1431
|
+
type: "object",
|
|
1432
|
+
properties: {
|
|
1433
|
+
window: { type: "string", enum: ["7d", "30d"] }
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
];
|
|
1438
|
+
var MCP_TOOLS_WITH_ARC = [...MCP_TOOLS, ...ARC_MCP_TOOLS];
|
|
1439
|
+
function resolveApiUrl(env = process.env) {
|
|
1440
|
+
return env.SUPERSENDTX_API_URL?.trim() || env.RANLA_API_URL?.trim() || void 0;
|
|
1441
|
+
}
|
|
1442
|
+
function resolveApiKey() {
|
|
1443
|
+
const key = process.env.SUPERSENDTX_API_KEY?.trim() || process.env.RANLA_API_KEY?.trim();
|
|
1444
|
+
if (!key) {
|
|
1445
|
+
throw new Error("Set RANLA_API_KEY (or SUPERSENDTX_API_KEY) to an API key");
|
|
1446
|
+
}
|
|
1447
|
+
if (!key.startsWith("stx_") && !key.startsWith("rnl_")) {
|
|
1448
|
+
throw new Error("The API key must start with rnl_ or stx_ (RANLA_API_KEY / SUPERSENDTX_API_KEY)");
|
|
1449
|
+
}
|
|
1450
|
+
return key;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// src/server.ts
|
|
1454
|
+
var MCP_SERVER_VERSION = version;
|
|
1455
|
+
var MCP_SERVER_INSTRUCTIONS = [
|
|
1456
|
+
"Ranla is an AI growth agent for a software business: lifecycle email, audiences, campaigns, pages, ads, and replies, plus transactional email from the same workspace.",
|
|
1457
|
+
'For growth work, or anything that needs judgement or research, send the request to Ranla with arc_message and show its reply. Use arc_ranla when the user names one specific step, such as "campaign list".',
|
|
1458
|
+
"Anything that sends, spends, or publishes comes back as pendingApproval and does nothing yet. Show it to the user and call arc_approve or arc_reject only after they answer.",
|
|
1459
|
+
"Use the mail tools (send_email, domains, templates, webhooks, suppressions) only when the user asks about application mail such as receipts or sign-in codes."
|
|
1460
|
+
].join("\n\n");
|
|
1461
|
+
var MCP_TOOL_SECURITY_SCHEMES = [{ type: "oauth2", scopes: ["full"] }];
|
|
1462
|
+
function withUpdateNotice(result, notice) {
|
|
1463
|
+
return { ...result, content: [...result.content, { type: "text", text: notice }] };
|
|
1464
|
+
}
|
|
1465
|
+
function createMcpServer(apiKey, baseUrl, options = {}) {
|
|
1466
|
+
const client = new SuperSendTX(apiKey, {
|
|
1467
|
+
...baseUrl ? { baseUrl } : {},
|
|
1468
|
+
fetch: fetchWithUserAgent(mcpUserAgent())
|
|
1469
|
+
});
|
|
1470
|
+
const knownUpdate = options.updateCheck?.result();
|
|
1471
|
+
const instructions = knownUpdate ? `${MCP_SERVER_INSTRUCTIONS}
|
|
1472
|
+
|
|
1473
|
+
${formatMcpAgentUpdateNotice(knownUpdate)}` : MCP_SERVER_INSTRUCTIONS;
|
|
1474
|
+
const server = new Server(
|
|
1475
|
+
{ name: mcpServerName(), version: MCP_SERVER_VERSION },
|
|
1476
|
+
{ capabilities: { tools: {} }, instructions }
|
|
1477
|
+
);
|
|
1478
|
+
let updateNoticeSent = false;
|
|
1479
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1480
|
+
tools: [...MCP_TOOLS, ...await loadArcMcpTools(apiKey, baseUrl)].map((tool) => {
|
|
1481
|
+
const annotations = mcpToolAnnotations(tool);
|
|
1482
|
+
return {
|
|
1483
|
+
name: tool.name,
|
|
1484
|
+
title: annotations.title,
|
|
1485
|
+
description: tool.description,
|
|
1486
|
+
inputSchema: tool.inputSchema,
|
|
1487
|
+
annotations,
|
|
1488
|
+
securitySchemes: MCP_TOOL_SECURITY_SCHEMES,
|
|
1489
|
+
_meta: { securitySchemes: MCP_TOOL_SECURITY_SCHEMES }
|
|
1490
|
+
};
|
|
1491
|
+
})
|
|
1492
|
+
}));
|
|
1493
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1494
|
+
const result = await callMcpTool(client, request.params.name, request.params.arguments ?? {}, {
|
|
1495
|
+
apiKey,
|
|
1496
|
+
baseUrl
|
|
1497
|
+
});
|
|
1498
|
+
const update = updateNoticeSent ? null : options.updateCheck?.result();
|
|
1499
|
+
if (!update) return result;
|
|
1500
|
+
updateNoticeSent = true;
|
|
1501
|
+
return withUpdateNotice(result, formatMcpAgentUpdateNotice(update));
|
|
1502
|
+
});
|
|
1503
|
+
return server;
|
|
1504
|
+
}
|
|
1505
|
+
async function runStdioServer() {
|
|
1506
|
+
const apiKey = resolveApiKey();
|
|
1507
|
+
const server = createMcpServer(apiKey, resolveApiUrl(), { updateCheck: startMcpUpdateCheck() });
|
|
1508
|
+
const transport = new StdioServerTransport();
|
|
1509
|
+
await server.connect(transport);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// src/oauth-metadata.ts
|
|
1513
|
+
var MCP_HTTP_PATH = "/mcp";
|
|
1514
|
+
function appOriginForMcpOrigin(mcpOrigin) {
|
|
1515
|
+
try {
|
|
1516
|
+
const host = new URL(mcpOrigin).hostname.toLowerCase();
|
|
1517
|
+
if (host === "mcp.ranla.ai") return "https://app.ranla.ai";
|
|
1518
|
+
if (host === "mcp.supersendtx.com") return "https://app.ranla.ai";
|
|
1519
|
+
} catch {
|
|
1520
|
+
}
|
|
1521
|
+
return (process.env.SUPERSENDTX_OAUTH_ISSUER?.trim() || process.env.SUPERSENDTX_APP_URL?.trim() || "https://app.ranla.ai").replace(/\/$/, "");
|
|
1522
|
+
}
|
|
1523
|
+
function buildProtectedResourceMetadata(origin) {
|
|
1524
|
+
const resource = `${origin.replace(/\/$/, "")}${MCP_HTTP_PATH}`;
|
|
1525
|
+
const appOrigin = appOriginForMcpOrigin(origin);
|
|
1526
|
+
return {
|
|
1527
|
+
resource,
|
|
1528
|
+
authorization_servers: [appOrigin],
|
|
1529
|
+
scopes_supported: ["full"],
|
|
1530
|
+
bearer_methods_supported: ["header"]
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
function buildWwwAuthenticateChallenge(origin) {
|
|
1534
|
+
const metadataUrl = `${origin.replace(/\/$/, "")}/.well-known/oauth-protected-resource`;
|
|
1535
|
+
return `Bearer error="invalid_token", error_description="OAuth authentication required", resource_metadata="${metadataUrl}"`;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// src/oauth-resolve.ts
|
|
1539
|
+
function appOriginForMcpOrigin2(mcpOrigin) {
|
|
1540
|
+
if (mcpOrigin) {
|
|
1541
|
+
try {
|
|
1542
|
+
const host = new URL(mcpOrigin).hostname.toLowerCase();
|
|
1543
|
+
if (host === "mcp.ranla.ai") return "https://app.ranla.ai";
|
|
1544
|
+
if (host === "mcp.supersendtx.com") return "https://app.supersendtx.com";
|
|
1545
|
+
} catch {
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return (process.env.SUPERSENDTX_APP_URL?.trim() || process.env.SUPERSENDTX_OAUTH_ISSUER?.trim() || "https://app.supersendtx.com").replace(/\/$/, "");
|
|
1549
|
+
}
|
|
1550
|
+
function getOAuthResolveUrl(mcpOrigin) {
|
|
1551
|
+
return `${appOriginForMcpOrigin2(mcpOrigin)}/api/mcp/oauth/resolve`;
|
|
1552
|
+
}
|
|
1553
|
+
async function resolveOAuthAccessToken(accessToken, mcpOrigin) {
|
|
1554
|
+
const resolveSecret = process.env.SUPERSENDTX_MCP_RESOLVE_SECRET?.trim() || process.env.SUPERSENDTX_CREDENTIALS_ENCRYPTION_KEY?.trim() || (process.env.NODE_ENV === "production" ? "" : "supersendtx-dev-mcp-resolve");
|
|
1555
|
+
if (!resolveSecret) return null;
|
|
1556
|
+
const response = await fetch(getOAuthResolveUrl(mcpOrigin), {
|
|
1557
|
+
method: "POST",
|
|
1558
|
+
headers: {
|
|
1559
|
+
authorization: `Bearer ${accessToken}`,
|
|
1560
|
+
"content-type": "application/json",
|
|
1561
|
+
"x-supersendtx-mcp-resolve": resolveSecret
|
|
1562
|
+
}
|
|
1563
|
+
});
|
|
1564
|
+
if (!response.ok) return null;
|
|
1565
|
+
const data = await response.json().catch(() => null);
|
|
1566
|
+
const apiKey = data?.apiKey?.trim();
|
|
1567
|
+
return apiKey || null;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// src/http.ts
|
|
1571
|
+
var OPENAI_APPS_CHALLENGE_PATH = "/.well-known/openai-apps-challenge";
|
|
1572
|
+
function sendJson(res, status, body, headers = {}) {
|
|
1573
|
+
const payload = JSON.stringify(body);
|
|
1574
|
+
res.writeHead(status, {
|
|
1575
|
+
"content-type": "application/json; charset=utf-8",
|
|
1576
|
+
"content-length": Buffer.byteLength(payload),
|
|
1577
|
+
...headers
|
|
1578
|
+
});
|
|
1579
|
+
res.end(payload);
|
|
1580
|
+
}
|
|
1581
|
+
function firstHeaderValue(value) {
|
|
1582
|
+
if (!value) return void 0;
|
|
1583
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
1584
|
+
const trimmed = raw?.split(",")[0]?.trim();
|
|
1585
|
+
return trimmed || void 0;
|
|
1586
|
+
}
|
|
1587
|
+
function getRequestOrigin(req) {
|
|
1588
|
+
const host = firstHeaderValue(req.headers["x-forwarded-host"]) || firstHeaderValue(req.headers.host) || "127.0.0.1";
|
|
1589
|
+
const proto = firstHeaderValue(req.headers["x-forwarded-proto"]) || "http";
|
|
1590
|
+
return `${proto}://${host}`;
|
|
1591
|
+
}
|
|
1592
|
+
async function resolveApiKeyFromAuthorization(authorization, baseUrl, mcpOrigin) {
|
|
1593
|
+
const bearer = parseBearerToken(authorization);
|
|
1594
|
+
if (!bearer) return null;
|
|
1595
|
+
if (isSuperSendTxApiKey(bearer)) return bearer;
|
|
1596
|
+
return resolveOAuthAccessToken(bearer, mcpOrigin);
|
|
1597
|
+
}
|
|
1598
|
+
async function handleHttpRequest(req, res, options = {}) {
|
|
1599
|
+
const origin = getRequestOrigin(req);
|
|
1600
|
+
const url = new URL(req.url || "/", origin);
|
|
1601
|
+
if (req.method === "OPTIONS") {
|
|
1602
|
+
res.writeHead(204, {
|
|
1603
|
+
"access-control-allow-origin": "*",
|
|
1604
|
+
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
|
|
1605
|
+
"access-control-allow-headers": "Authorization, Content-Type, Accept, Mcp-Session-Id, X-Supersendtx-Mcp-Resolve"
|
|
1606
|
+
});
|
|
1607
|
+
res.end();
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
if (url.pathname === "/health") {
|
|
1611
|
+
sendJson(res, 200, { ok: true, service: "ranla-mcp" });
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
if (url.pathname === OPENAI_APPS_CHALLENGE_PATH) {
|
|
1615
|
+
const token = process.env.OPENAI_APPS_CHALLENGE_TOKEN?.trim();
|
|
1616
|
+
if (!token) {
|
|
1617
|
+
sendJson(res, 404, { error: "Not found" });
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
res.writeHead(200, {
|
|
1621
|
+
"content-type": "text/plain; charset=utf-8",
|
|
1622
|
+
"content-length": Buffer.byteLength(token),
|
|
1623
|
+
"cache-control": "no-store"
|
|
1624
|
+
});
|
|
1625
|
+
res.end(token);
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
if (url.pathname === "/.well-known/oauth-protected-resource") {
|
|
1629
|
+
sendJson(res, 200, buildProtectedResourceMetadata(origin), {
|
|
1630
|
+
"cache-control": "public, max-age=300, s-maxage=3600"
|
|
1631
|
+
});
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
if (url.pathname !== "/mcp" && url.pathname !== "/") {
|
|
1635
|
+
sendJson(res, 404, { error: "Not found. MCP endpoint is POST/GET /mcp" });
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
const apiKey = await resolveApiKeyFromAuthorization(
|
|
1639
|
+
typeof req.headers.authorization === "string" ? req.headers.authorization : void 0,
|
|
1640
|
+
options.baseUrl,
|
|
1641
|
+
origin
|
|
1642
|
+
);
|
|
1643
|
+
if (!apiKey) {
|
|
1644
|
+
sendJson(
|
|
1645
|
+
res,
|
|
1646
|
+
401,
|
|
1647
|
+
{ error: "Missing or invalid Authorization. Use Bearer stx_\u2026 / rnl_\u2026 or complete MCP OAuth." },
|
|
1648
|
+
{ "www-authenticate": buildWwwAuthenticateChallenge(origin) }
|
|
1649
|
+
);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
const server = createMcpServer(apiKey, options.baseUrl);
|
|
1653
|
+
const transport = new StreamableHTTPServerTransport({
|
|
1654
|
+
sessionIdGenerator: void 0
|
|
1655
|
+
});
|
|
1656
|
+
try {
|
|
1657
|
+
await server.connect(transport);
|
|
1658
|
+
await transport.handleRequest(req, res);
|
|
1659
|
+
} finally {
|
|
1660
|
+
await transport.close().catch(() => void 0);
|
|
1661
|
+
await server.close().catch(() => void 0);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
async function runHttpServer(options) {
|
|
1665
|
+
const host = options.host ?? "127.0.0.1";
|
|
1666
|
+
const port = options.port;
|
|
1667
|
+
const baseUrl = resolveApiUrl();
|
|
1668
|
+
const httpServer = createServer((req, res) => {
|
|
1669
|
+
void handleHttpRequest(req, res, { baseUrl }).catch((error) => {
|
|
1670
|
+
if (!res.headersSent) {
|
|
1671
|
+
sendJson(res, 500, {
|
|
1672
|
+
error: error instanceof Error ? error.message : "Internal server error"
|
|
1673
|
+
});
|
|
1674
|
+
} else {
|
|
1675
|
+
res.end();
|
|
1676
|
+
}
|
|
1677
|
+
});
|
|
1678
|
+
});
|
|
1679
|
+
await new Promise((resolve, reject) => {
|
|
1680
|
+
httpServer.once("error", reject);
|
|
1681
|
+
httpServer.listen(port, host, () => resolve());
|
|
1682
|
+
});
|
|
1683
|
+
console.error(`MCP HTTP server listening on http://${host}:${port}/mcp`);
|
|
1684
|
+
console.error("Authenticate with Bearer rnl_\u2026 / stx_\u2026 or an MCP OAuth access token");
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// src/index.ts
|
|
1688
|
+
async function main() {
|
|
1689
|
+
applyRanlaMcpEnv();
|
|
1690
|
+
const options = parseCliArgs(process.argv);
|
|
1691
|
+
if (options.mode === "help") {
|
|
1692
|
+
printHelp();
|
|
1693
|
+
process.exit(0);
|
|
1694
|
+
}
|
|
1695
|
+
if (options.mode === "http") {
|
|
1696
|
+
await runHttpServer({ port: options.port, host: options.host });
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
await runStdioServer();
|
|
1700
|
+
}
|
|
1701
|
+
main().catch((error) => {
|
|
1702
|
+
console.error(error instanceof Error ? error.message : error);
|
|
1703
|
+
process.exit(1);
|
|
1704
|
+
});
|