ranla-mcp 0.7.0 → 0.8.3

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +907 -133
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -262,7 +262,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
262
262
  import { SuperSendTX } from "@supersend/ranla";
263
263
 
264
264
  // package.json
265
- var version = "0.7.0";
265
+ var version = "0.8.3";
266
266
 
267
267
  // src/annotations.ts
268
268
  var READ = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
@@ -297,25 +297,25 @@ var FIXED_TOOL_HINTS = {
297
297
  publish_template: WRITE,
298
298
  send_test_webhook_event: { ...WRITE, ...OUT },
299
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 },
300
+ ranla_list_threads: { ...READ, title: "List Ranla threads" },
301
+ ranla_get_thread: { ...READ, title: "Read a Ranla thread" },
302
+ ranla_open_thread: { ...WRITE, title: "Open a Ranla thread", idempotentHint: true },
303
303
  /**
304
304
  * Ranla researches the web, and a turn can delete or overwrite workspace
305
305
  * records. What reaches people or spends stops at an approval.
306
306
  */
307
- arc_message: { ...DELETE, ...OUT, title: "Message Ranla" },
307
+ ranla_message: { ...DELETE, ...OUT, title: "Message Ranla" },
308
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" },
309
+ ranla_command: { ...DELETE, ...OUT, title: "Run a Ranla command" },
310
+ ranla_list_approvals: { ...READ, title: "List pending approvals" },
311
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" },
312
+ ranla_approve: { ...DELETE, ...OUT, title: "Approve a Ranla step" },
313
313
  /** Rejecting discards the proposal. */
314
- arc_reject: { ...DELETE, title: "Reject a Ranla step" }
314
+ ranla_reject: { ...DELETE, title: "Reject a Ranla step" }
315
315
  };
316
316
  var UPPER = /* @__PURE__ */ new Set(["ai", "aeo", "api", "dns", "id", "seo", "url"]);
317
317
  function titleFromToolName(name) {
318
- const words = name.replace(/^arc_/, "").split("_").filter(Boolean);
318
+ const words = name.replace(/^(?:ranla|arc)_/, "").split("_").filter(Boolean);
319
319
  return words.map((word, index) => {
320
320
  if (UPPER.has(word)) return word.toUpperCase();
321
321
  return index === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word;
@@ -374,8 +374,8 @@ function openObjectSchema(description) {
374
374
  };
375
375
  }
376
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.`;
377
+ var RANLA_COMMAND_TOOL_NAME = "ranla_command";
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 ranla_approve with its messageId applies it. For strategy, or anything that needs ${RANLA_AGENT_NAME}'s judgement or research, use ranla_message.`;
379
379
  function ranlaCommandTool(index) {
380
380
  return {
381
381
  name: RANLA_COMMAND_TOOL_NAME,
@@ -388,7 +388,7 @@ ${index}` : RANLA_COMMAND_INTRO,
388
388
  properties: {
389
389
  command: { type: "string", description: '"<noun> <verb>", or "help <noun>".' },
390
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." }
391
+ threadId: { type: "string", description: "Ranla thread to record it in (from ranla_open_thread). Optional." }
392
392
  },
393
393
  required: ["command"]
394
394
  },
@@ -397,8 +397,8 @@ ${index}` : RANLA_COMMAND_INTRO,
397
397
  }
398
398
  var META_TOOLS = [
399
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.",
400
+ name: "ranla_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 ranla_get_thread.",
402
402
  inputSchema: {
403
403
  type: "object",
404
404
  properties: {
@@ -412,12 +412,12 @@ var META_TOOLS = [
412
412
  kind: "meta"
413
413
  },
414
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.",
415
+ name: "ranla_get_thread",
416
+ description: "Read a Ranla thread: its messages (role, text, tools used, artifacts by id, approvals waiting on ranla_approve), newest page first. Pass earlierBefore as before for older messages.",
417
417
  inputSchema: {
418
418
  type: "object",
419
419
  properties: {
420
- threadId: { type: "string", description: "From arc_list_threads or arc_ensure_thread." },
420
+ threadId: { type: "string", description: "From ranla_list_threads or ranla_open_thread." },
421
421
  before: {
422
422
  type: "string",
423
423
  description: "ISO timestamp from earlierBefore: the page of messages older than it."
@@ -428,7 +428,7 @@ var META_TOOLS = [
428
428
  kind: "meta"
429
429
  },
430
430
  {
431
- name: "arc_ensure_thread",
431
+ name: "ranla_open_thread",
432
432
  description: "Find or create a durable Ranla thread for MCP (default title \u201CMCP\u201D). Returns { threadId, title }.",
433
433
  inputSchema: {
434
434
  type: "object",
@@ -440,43 +440,49 @@ var META_TOOLS = [
440
440
  kind: "meta"
441
441
  },
442
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.",
443
+ name: "ranla_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 ranla_get_thread reads the answer later.",
445
445
  inputSchema: {
446
446
  type: "object",
447
447
  properties: {
448
448
  text: { type: "string", description: `Message for ${RANLA_AGENT_NAME}` },
449
- threadId: { type: "string", description: "Ranla thread id (from arc_ensure_thread)" }
449
+ threadId: { type: "string", description: "Ranla thread id (from ranla_open_thread)" }
450
450
  },
451
451
  required: ["text"]
452
452
  },
453
453
  kind: "meta"
454
454
  },
455
455
  {
456
- name: "arc_list_approvals",
456
+ name: "ranla_list_approvals",
457
457
  description: "List pending Ranla approvals across threads.",
458
458
  inputSchema: openObjectSchema(),
459
459
  kind: "meta"
460
460
  },
461
461
  {
462
- name: "arc_approve",
462
+ name: "ranla_approve",
463
463
  description: "Approve a pending Ranla approval and resume the run.",
464
464
  inputSchema: {
465
465
  type: "object",
466
466
  properties: {
467
- messageId: { type: "string" }
467
+ messageId: {
468
+ type: "string",
469
+ description: "The pending approval's messageId, from ranla_list_approvals or a pendingApproval result"
470
+ }
468
471
  },
469
472
  required: ["messageId"]
470
473
  },
471
474
  kind: "meta"
472
475
  },
473
476
  {
474
- name: "arc_reject",
477
+ name: "ranla_reject",
475
478
  description: "Reject a pending Ranla approval and resume the run.",
476
479
  inputSchema: {
477
480
  type: "object",
478
481
  properties: {
479
- messageId: { type: "string" }
482
+ messageId: {
483
+ type: "string",
484
+ description: "The pending approval's messageId, from ranla_list_approvals or a pendingApproval result"
485
+ }
480
486
  },
481
487
  required: ["messageId"]
482
488
  },
@@ -509,10 +515,24 @@ async function arcFetch(origin, apiKey, path, init = {}) {
509
515
  const body = await response.json().catch(() => ({}));
510
516
  return { ok: response.ok, status: response.status, body };
511
517
  }
512
- var ARC_TOOL_PREFIX = "arc_";
518
+ var RANLA_TOOL_PREFIX = "ranla_";
519
+ var LEGACY_TOOL_PREFIX = "arc_";
513
520
  var CATALOG_TIMEOUT_MS = 4e3;
521
+ var LEGACY_TOOL_NAMES = {
522
+ arc_message: "ranla_message",
523
+ arc_ranla: "ranla_command",
524
+ arc_list_threads: "ranla_list_threads",
525
+ arc_get_thread: "ranla_get_thread",
526
+ arc_ensure_thread: "ranla_open_thread",
527
+ arc_list_approvals: "ranla_list_approvals",
528
+ arc_approve: "ranla_approve",
529
+ arc_reject: "ranla_reject"
530
+ };
531
+ function currentToolName(name) {
532
+ return LEGACY_TOOL_NAMES[name] ?? name;
533
+ }
514
534
  function isArcMcpToolName(name) {
515
- return name.startsWith(ARC_TOOL_PREFIX);
535
+ return name.startsWith(RANLA_TOOL_PREFIX) || name.startsWith(LEGACY_TOOL_PREFIX);
516
536
  }
517
537
  async function fetchCommandIndex(apiKey, baseUrl) {
518
538
  try {
@@ -536,7 +556,7 @@ var ARC_MESSAGE_POLL_MS = 2e3;
536
556
  var ARC_THREAD_POLL_MS = 5e3;
537
557
  var ARC_MESSAGE_REQUEST_TIMEOUT_MS = 11e4;
538
558
  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.";
559
+ var STILL_RUNNING_NOTE = "Ranla is still working on this. Read the answer with ranla_get_thread in a minute or two.";
540
560
  var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
541
561
  function turnFromThread(threadId, messages, extra) {
542
562
  const lastFounder = messages.map((message) => message.role).lastIndexOf("user");
@@ -570,8 +590,8 @@ async function followThread(origin, apiKey, threadId, deadline, extra) {
570
590
  const response = await arcFetch(origin, apiKey, `/api/growth/threads/${encodeURIComponent(threadId)}`).catch(
571
591
  () => null
572
592
  );
573
- const thread = response?.ok ? response.body.thread : void 0;
574
- if (thread && thread.running !== true) {
593
+ const thread2 = response?.ok ? response.body.thread : void 0;
594
+ if (thread2 && thread2.running !== true) {
575
595
  const messages = Array.isArray(response.body.messages) ? response.body.messages : [];
576
596
  return turnFromThread(threadId, messages, extra);
577
597
  }
@@ -581,7 +601,7 @@ async function followThread(origin, apiKey, threadId, deadline, extra) {
581
601
  await sleep(ARC_THREAD_POLL_MS);
582
602
  }
583
603
  }
584
- async function sendArcMessage(origin, apiKey, threadId, text) {
604
+ async function sendArcMessage(origin, apiKey, threadId, text, options = {}) {
585
605
  const path = `/api/growth/threads/${encodeURIComponent(threadId)}/messages`;
586
606
  const idempotencyKey = randomUUID();
587
607
  const deadline = Date.now() + ARC_MESSAGE_MAX_WAIT_MS;
@@ -596,7 +616,7 @@ async function sendArcMessage(origin, apiKey, threadId, text) {
596
616
  const response = await arcFetch(origin, apiKey, path, {
597
617
  method: "POST",
598
618
  headers: { "Idempotency-Key": idempotencyKey },
599
- body: JSON.stringify({ text, sync: true }),
619
+ body: JSON.stringify({ text, sync: true, ...options.askOnly ? { context: { askOnly: true } } : {} }),
600
620
  signal: AbortSignal.timeout(ARC_MESSAGE_REQUEST_TIMEOUT_MS)
601
621
  }).catch(() => null);
602
622
  if (!response || LOST_REQUEST_STATUSES.has(response.status)) {
@@ -631,27 +651,32 @@ function optString(args, key) {
631
651
  const value = String(args[key]).trim();
632
652
  return value || void 0;
633
653
  }
634
- async function callArcMcpTool(apiKey, name, args, baseUrl) {
654
+ async function runRanlaCommand(apiKey, command, input, baseUrl, threadId) {
655
+ const origin = resolveArcAppOrigin({ baseUrl, apiKey });
656
+ const response = await arcFetch(origin, apiKey, "/api/growth/commands", {
657
+ method: "POST",
658
+ body: JSON.stringify({ command, input, ...threadId ? { threadId } : {} })
659
+ });
660
+ if (!response.ok) {
661
+ throw new Error(
662
+ typeof response.body.error === "string" ? response.body.error : `Ranla command failed (${response.status})`
663
+ );
664
+ }
665
+ return response.body;
666
+ }
667
+ async function callArcMcpTool(apiKey, name, args, baseUrl, options = {}) {
635
668
  if (!isArcMcpToolName(name)) return null;
669
+ name = currentToolName(name);
636
670
  const origin = resolveArcAppOrigin({ baseUrl, apiKey });
637
671
  if (name === RANLA_COMMAND_TOOL_NAME) {
638
672
  const command = optString(args, "command");
639
673
  if (!command) throw new Error("Missing required argument: command");
640
674
  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;
675
+ return runRanlaCommand(apiKey, command, input, baseUrl, optString(args, "threadId"));
652
676
  }
653
677
  if (!META_TOOLS.some((tool) => tool.name === name)) {
654
- const growthTool = name.slice(ARC_TOOL_PREFIX.length);
678
+ if (!name.startsWith(LEGACY_TOOL_PREFIX)) throw new Error(`Unknown tool: ${name}`);
679
+ const growthTool = name.slice(LEGACY_TOOL_PREFIX.length);
655
680
  const response = await arcFetch(origin, apiKey, `/api/growth/tools/${encodeURIComponent(growthTool)}`, {
656
681
  method: "POST",
657
682
  body: JSON.stringify({ input: args })
@@ -664,7 +689,7 @@ async function callArcMcpTool(apiKey, name, args, baseUrl) {
664
689
  return response.body.result ?? response.body;
665
690
  }
666
691
  switch (name) {
667
- case "arc_list_threads": {
692
+ case "ranla_list_threads": {
668
693
  const query = new URLSearchParams();
669
694
  const limit = optString(args, "limit");
670
695
  const before = optString(args, "before");
@@ -679,7 +704,7 @@ async function callArcMcpTool(apiKey, name, args, baseUrl) {
679
704
  }
680
705
  return response.body;
681
706
  }
682
- case "arc_get_thread": {
707
+ case "ranla_get_thread": {
683
708
  const threadId = optString(args, "threadId");
684
709
  if (!threadId) throw new Error("Missing required argument: threadId");
685
710
  const before = optString(args, "before");
@@ -695,7 +720,7 @@ async function callArcMcpTool(apiKey, name, args, baseUrl) {
695
720
  }
696
721
  return response.body;
697
722
  }
698
- case "arc_ensure_thread": {
723
+ case "ranla_open_thread": {
699
724
  const existingId = optString(args, "threadId");
700
725
  if (existingId) {
701
726
  const byId = await arcFetch(
@@ -704,9 +729,9 @@ async function callArcMcpTool(apiKey, name, args, baseUrl) {
704
729
  `/api/growth/threads/${encodeURIComponent(existingId)}`
705
730
  );
706
731
  if (byId.ok) {
707
- const thread2 = byId.body.thread;
708
- if (thread2?.id) {
709
- return { threadId: thread2.id, title: thread2.title ?? null };
732
+ const thread3 = byId.body.thread;
733
+ if (thread3?.id) {
734
+ return { threadId: thread3.id, title: thread3.title ?? null };
710
735
  }
711
736
  }
712
737
  }
@@ -732,21 +757,21 @@ async function callArcMcpTool(apiKey, name, args, baseUrl) {
732
757
  typeof created.body.error === "string" ? created.body.error : `Create thread failed (${created.status})`
733
758
  );
734
759
  }
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 };
760
+ const thread2 = created.body.thread;
761
+ if (!thread2?.id) throw new Error("Create thread returned no id");
762
+ return { threadId: thread2.id, title: thread2.title ?? title };
738
763
  }
739
- case "arc_message": {
764
+ case "ranla_message": {
740
765
  const text = optString(args, "text");
741
766
  if (!text) throw new Error("Missing required argument: text");
742
767
  let threadId = optString(args, "threadId");
743
768
  if (!threadId) {
744
- const ensured = await callArcMcpTool(apiKey, "arc_ensure_thread", {}, baseUrl);
769
+ const ensured = await callArcMcpTool(apiKey, "ranla_open_thread", {}, baseUrl);
745
770
  threadId = ensured.threadId;
746
771
  }
747
- return sendArcMessage(origin, apiKey, threadId, text);
772
+ return sendArcMessage(origin, apiKey, threadId, text, options);
748
773
  }
749
- case "arc_list_approvals": {
774
+ case "ranla_list_approvals": {
750
775
  const response = await arcFetch(origin, apiKey, "/api/growth/approvals");
751
776
  if (!response.ok) {
752
777
  throw new Error(
@@ -755,11 +780,11 @@ async function callArcMcpTool(apiKey, name, args, baseUrl) {
755
780
  }
756
781
  return response.body;
757
782
  }
758
- case "arc_approve":
759
- case "arc_reject": {
783
+ case "ranla_approve":
784
+ case "ranla_reject": {
760
785
  const messageId = optString(args, "messageId");
761
786
  if (!messageId) throw new Error("Missing required argument: messageId");
762
- const decision = name === "arc_approve" ? "approve" : "reject";
787
+ const decision = name === "ranla_approve" ? "approve" : "reject";
763
788
  const response = await arcFetch(
764
789
  origin,
765
790
  apiKey,
@@ -781,13 +806,725 @@ async function callArcMcpTool(apiKey, name, args, baseUrl) {
781
806
  }
782
807
  }
783
808
 
784
- // src/tools.ts
785
- function textResult(value, isError = false) {
809
+ // src/chatgpt-tools.ts
810
+ var days = (what, fallback) => ({
811
+ type: "number",
812
+ description: `${what} window in days. Default ${fallback}.`
813
+ });
814
+ function schema(properties = {}) {
815
+ return { type: "object", properties, additionalProperties: false };
816
+ }
817
+ var CHATGPT_COMMAND_TOOLS = [
818
+ {
819
+ name: "get_setup_status",
820
+ command: "setup status",
821
+ description: "What the workspace has connected and what is left: blockers, the next setup step, whether Ranla can draft and send, and whether the site's tracking is in.",
822
+ inputSchema: schema()
823
+ },
824
+ {
825
+ name: "get_growth_health",
826
+ command: "growth get",
827
+ description: 'The funnel for this workspace over a recent window: contacts added, how many activated, and how many paid. Use for "how is growth going".',
828
+ inputSchema: schema({ days: days("Trailing", "the app default") })
829
+ },
830
+ {
831
+ name: "get_revenue_health",
832
+ command: "revenue get",
833
+ description: "Revenue scoreboard: the conversions and revenue Ranla's channels produced (email, paid ads, other), by channel.",
834
+ inputSchema: schema({ days: days("Trailing", "the app default") })
835
+ },
836
+ {
837
+ name: "list_goals",
838
+ command: "goal list",
839
+ description: "The conversion goals this workspace measures: the event, its label, and whether it is active.",
840
+ inputSchema: schema()
841
+ },
842
+ {
843
+ name: "get_search_performance",
844
+ command: "seo performance",
845
+ description: "What Google Search Console reports for the site: clicks, impressions, click-through rate, average position, and the queries and pages that earned the most, including pages Ranla shipped. Defaults to the last 28 days.",
846
+ inputSchema: schema({
847
+ from: { type: "string", description: "Start day, YYYY-MM-DD, for a window other than the last 28 days." },
848
+ to: { type: "string", description: "End day, YYYY-MM-DD. Default: the latest day Google has reported." }
849
+ })
850
+ },
851
+ {
852
+ name: "list_search_opportunities",
853
+ command: "seo opportunities",
854
+ description: "Searches the site could win that Ranla found: the pages it proposes to write, and the ones it decided against with its reasons.",
855
+ inputSchema: schema({
856
+ include: {
857
+ type: "string",
858
+ enum: ["proposed", "refused", "both"],
859
+ description: "Which to return. Default both."
860
+ }
861
+ })
862
+ },
863
+ {
864
+ name: "get_ai_answer_status",
865
+ command: "aeo status",
866
+ description: "How AI answer engines (ChatGPT, Perplexity, Gemini, Claude, Google AI) see the product: the share of answers that name it, its position, sentiment, and the competitors named instead.",
867
+ inputSchema: schema({ days: { type: "number", description: "The window in days, 1-365. Default 28." } })
868
+ },
869
+ {
870
+ name: "list_ai_answer_questions",
871
+ command: "aeo-prompt list",
872
+ description: "The buyer questions tracked for AI answers, each with whether the engines name the product and which competitors they name instead, lowest visibility first.",
873
+ inputSchema: schema({
874
+ status: {
875
+ type: "string",
876
+ enum: ["active", "suggested", "paused", "all"],
877
+ description: "Which questions. Default active."
878
+ },
879
+ limit: { type: "number", description: "Max questions to return." }
880
+ })
881
+ },
882
+ {
883
+ name: "list_reddit_threads",
884
+ command: "reddit-thread list",
885
+ description: "Reddit threads Ranla found where the product's buyers ask for help, newest and most relevant first, with each thread's status (new, drafted, posted\u2026).",
886
+ inputSchema: schema({
887
+ lane: {
888
+ type: "string",
889
+ enum: ["fresh", "ranking", "any"],
890
+ description: "fresh: new threads (default). ranking: threads that rank on Google or show up in AI answers."
891
+ },
892
+ status: {
893
+ type: "string",
894
+ enum: ["new", "drafted", "approved", "posted", "dismissed", "any"],
895
+ description: "Default new."
896
+ },
897
+ subreddit: { type: "string", description: "Only this subreddit, without r/." }
898
+ })
899
+ },
900
+ {
901
+ name: "list_reddit_replies",
902
+ command: "reddit-draft list",
903
+ description: "Replies and posts Ranla drafted for Reddit, and the ones it posted, with their status.",
904
+ inputSchema: schema({
905
+ status: {
906
+ type: "string",
907
+ enum: ["draft", "approved", "scheduled", "posted", "rejected", "failed", "any"],
908
+ description: "Default any."
909
+ },
910
+ subreddit: { type: "string", description: "Only this subreddit, without r/." }
911
+ })
912
+ },
913
+ {
914
+ name: "list_ad_plans",
915
+ command: "ad-plan list",
916
+ description: "Search ad campaigns Ranla planned, newest first: waiting for the founder's yes, applied to the ad account, or set aside.",
917
+ inputSchema: schema({
918
+ status: {
919
+ type: "string",
920
+ enum: ["proposed", "approved", "applied", "failed", "rejected", "all"],
921
+ description: "proposed (default): waiting for a yes. applied: created in the ad account. all: every plan."
922
+ },
923
+ limit: { type: "integer", description: "Max plans to return. Default 20." }
924
+ })
925
+ },
926
+ {
927
+ name: "list_email_campaigns",
928
+ command: "campaign list",
929
+ description: "The workspace's email campaigns with their status, and whether one that is sending has stalled.",
930
+ inputSchema: schema({
931
+ status: {
932
+ type: "string",
933
+ enum: ["draft", "materializing", "scheduled", "sending", "paused", "sent", "cancelled", "failed"],
934
+ description: "Only campaigns in this status."
935
+ },
936
+ query: { type: "string", description: "Name contains this (case-insensitive)." }
937
+ })
938
+ },
939
+ {
940
+ name: "get_email_performance",
941
+ command: "campaign performance",
942
+ description: "Send results for one email campaign, or across recent ones: sent, delivered, opened, clicked, replies, conversions and revenue.",
943
+ inputSchema: schema({
944
+ campaignId: { type: "string", description: "One campaign, from list_email_campaigns. Omit for all campaigns." },
945
+ days: { type: "integer", description: "Trailing window in days. Default 30." }
946
+ })
947
+ }
948
+ ];
949
+ var COMMAND_BY_TOOL = new Map(CHATGPT_COMMAND_TOOLS.map((tool) => [tool.name, tool.command]));
950
+ function chatGptCommandFor(name) {
951
+ return COMMAND_BY_TOOL.get(name);
952
+ }
953
+ var CHATGPT_EXCLUDED_TOOLS = /* @__PURE__ */ new Set([
954
+ "ranla_message",
955
+ "ranla_open_thread",
956
+ "ranla_command",
957
+ "ranla_approve",
958
+ "ranla_reject",
959
+ "create_domain",
960
+ "apply_domain_dns",
961
+ "verify_domain"
962
+ ]);
963
+ var CHATGPT_WORDING = [
964
+ ["Pass to ranla_approve or ranla_reject", "Approval id. The founder approves or rejects it in Ranla, at url"],
965
+ ["The step waiting on ranla_approve. It does nothing until approved.", "The step waiting for the founder's yes in Ranla. It does nothing until approved."],
966
+ ["Message id; for a pending approval, the messageId ranla_approve takes", "Message id"],
967
+ ["Pass to ranla_message and ranla_command", "Thread id"],
968
+ ["From ranla_list_threads or ranla_open_thread.", "From ranla_list_threads."],
969
+ ["approvals waiting on ranla_approve", "approvals waiting for the founder's yes"]
970
+ ];
971
+ function forChatGpt(value) {
972
+ if (typeof value === "string") {
973
+ return CHATGPT_WORDING.reduce((text, [from, to]) => text.split(from).join(to), value);
974
+ }
975
+ if (Array.isArray(value)) return value.map((item) => forChatGpt(item));
976
+ if (value && typeof value === "object") {
977
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, forChatGpt(item)]));
978
+ }
979
+ return value;
980
+ }
981
+ var CHATGPT_SERVER_INSTRUCTIONS = [
982
+ "Ranla is an AI growth engineer for a software business: pages that rank on Google, mentions in AI answers, Reddit replies, ads, and the emails that turn sign-ups into customers, plus transactional email from the same workspace.",
983
+ "To answer a question about growth, search, AI answers, Reddit, ads or email, call the matching read tools (get_growth_health, get_search_performance, list_reddit_threads\u2026) and answer from what they return. Combine several when the question needs it. Every number you quote should come from a tool result.",
984
+ "These tools read. Ranla does the work in Ranla: when the answer is something to do, say what and tell the founder they can ask Ranla to do it there. What is already waiting for their yes is in ranla_list_approvals, each with the url to approve it in Ranla. Past conversations with Ranla are in ranla_list_threads and ranla_get_thread.",
985
+ "Use the mail tools (send_email, templates, webhooks, suppressions) only when the user asks about application mail such as receipts or sign-in codes."
986
+ ].join("\n\n");
987
+
988
+ // src/output-schemas.ts
989
+ var str = (description) => ({ type: "string", ...description ? { description } : {} });
990
+ var nullableStr = (description) => ({
991
+ type: ["string", "null"],
992
+ ...description ? { description } : {}
993
+ });
994
+ var num = (description) => ({ type: "number", ...description ? { description } : {} });
995
+ var bool = (description) => ({ type: "boolean", ...description ? { description } : {} });
996
+ var nullableBool = (description) => ({
997
+ type: ["boolean", "null"],
998
+ ...description ? { description } : {}
999
+ });
1000
+ var strList = (description) => ({
1001
+ type: "array",
1002
+ items: { type: "string" },
1003
+ ...description ? { description } : {}
1004
+ });
1005
+ var listOf = (items, description) => ({
1006
+ type: "array",
1007
+ items,
1008
+ ...description ? { description } : {}
1009
+ });
1010
+ var object = (properties, required = [], description) => ({
1011
+ type: "object",
1012
+ properties,
1013
+ ...required.length > 0 ? { required } : {},
1014
+ ...description ? { description } : {}
1015
+ });
1016
+ var openObject = (description) => ({ type: "object", additionalProperties: true, description });
1017
+ function output(properties, required, description) {
786
1018
  return {
787
- content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
788
- ...isError ? { isError: true } : {}
1019
+ type: "object",
1020
+ properties,
1021
+ ...required.length > 0 ? { required } : {},
1022
+ ...description ? { description } : {}
789
1023
  };
790
1024
  }
1025
+ function page(key, item, description) {
1026
+ return output(
1027
+ {
1028
+ [key]: listOf(item, description),
1029
+ has_more: bool("True when another page follows"),
1030
+ next_cursor: nullableStr("Pass as cursor for the next page; null on the last page")
1031
+ },
1032
+ [key]
1033
+ );
1034
+ }
1035
+ var sendResult = output(
1036
+ {
1037
+ id: str("Email id (msg_\u2026)"),
1038
+ status: str("Delivery status, e.g. queued, scheduled, sent, cancelled")
1039
+ },
1040
+ ["id", "status"]
1041
+ );
1042
+ var email = object(
1043
+ {
1044
+ id: str("Email id (msg_\u2026)"),
1045
+ from: str(),
1046
+ to: strList(),
1047
+ cc: strList(),
1048
+ bcc: strList(),
1049
+ reply_to: strList(),
1050
+ subject: str(),
1051
+ status: str(),
1052
+ last_event: nullableStr("Latest delivery event"),
1053
+ bounce_reason: nullableStr(),
1054
+ tags: { description: "Tags set on the send" },
1055
+ scheduled_at: nullableStr("ISO 8601"),
1056
+ cancelled_at: nullableStr("ISO 8601"),
1057
+ created_at: str("ISO 8601"),
1058
+ sent_at: nullableStr("ISO 8601"),
1059
+ delivered_at: nullableStr("ISO 8601"),
1060
+ bounced_at: nullableStr("ISO 8601")
1061
+ },
1062
+ ["id"]
1063
+ );
1064
+ var receivedEmail = object(
1065
+ {
1066
+ id: str("Received email id"),
1067
+ domain_id: str(),
1068
+ domain_name: str(),
1069
+ status: str(),
1070
+ rcpt_to: str("Address it was delivered to"),
1071
+ mail_from: nullableStr("Envelope sender"),
1072
+ from: str(),
1073
+ to: strList(),
1074
+ cc: strList(),
1075
+ subject: str(),
1076
+ spam_status: nullableStr(),
1077
+ bounced: bool(),
1078
+ received_with_ssl: nullableBool(),
1079
+ message_id: nullableStr(),
1080
+ in_reply_to: nullableStr(),
1081
+ references: strList(),
1082
+ attachment_count: num(),
1083
+ attachments: listOf(
1084
+ object({
1085
+ filename: str(),
1086
+ content_type: str(),
1087
+ size: num("Bytes"),
1088
+ content_id: nullableStr(),
1089
+ data: str("Base64 content, when included")
1090
+ })
1091
+ ),
1092
+ text_body: nullableStr(),
1093
+ html_body: nullableStr(),
1094
+ auto_submitted: nullableStr(),
1095
+ received_at: str("ISO 8601"),
1096
+ created_at: str("ISO 8601")
1097
+ },
1098
+ ["id"]
1099
+ );
1100
+ var domain = object(
1101
+ {
1102
+ id: str("Domain id"),
1103
+ name: str("Domain name"),
1104
+ status: str("Verification status"),
1105
+ created_at: str("ISO 8601"),
1106
+ verified_at: nullableStr("ISO 8601"),
1107
+ open_tracking: bool(),
1108
+ click_tracking: bool(),
1109
+ tls_mode: str(),
1110
+ inbound_enabled: bool(),
1111
+ inbound_status: str("disabled, pending, active, or error"),
1112
+ inbound_error: nullableStr()
1113
+ },
1114
+ ["id", "name"]
1115
+ );
1116
+ var dnsRecord = object(
1117
+ {
1118
+ type: str("Record type: TXT, CNAME, MX\u2026"),
1119
+ host: str(),
1120
+ value: str(),
1121
+ purpose: str("What the record is for, e.g. spf, dkim, dmarc"),
1122
+ priority: num("MX preference")
1123
+ },
1124
+ ["type", "host", "value"]
1125
+ );
1126
+ var dnsAnalysis = openObject("DMARC and BIMI findings for the domain");
1127
+ var domainDnsFields = {
1128
+ domain,
1129
+ records: listOf(dnsRecord, "Sending DNS records to add at the DNS provider"),
1130
+ analysis: dnsAnalysis,
1131
+ inbound_records: listOf(dnsRecord, "Records for inbound receiving"),
1132
+ inbound_note: str(),
1133
+ tracking_dns: object(
1134
+ {
1135
+ required: bool("True when open or click tracking needs its own record"),
1136
+ host: nullableStr(),
1137
+ target: nullableStr(),
1138
+ ok: nullableBool()
1139
+ },
1140
+ [],
1141
+ "The tracking record, when tracking is on"
1142
+ )
1143
+ };
1144
+ var webhook = object(
1145
+ {
1146
+ id: str("Webhook id"),
1147
+ url: str(),
1148
+ events: strList("Event types delivered"),
1149
+ enabled: bool(),
1150
+ created_at: str("ISO 8601"),
1151
+ updated_at: str("ISO 8601")
1152
+ },
1153
+ ["id", "url"]
1154
+ );
1155
+ var suppression = object(
1156
+ {
1157
+ id: str("Suppression id"),
1158
+ email: str(),
1159
+ reason: nullableStr(),
1160
+ source: str(),
1161
+ created_at: str("ISO 8601"),
1162
+ updated_at: str("ISO 8601")
1163
+ },
1164
+ ["id", "email"]
1165
+ );
1166
+ var template = object(
1167
+ {
1168
+ id: str("Template id"),
1169
+ name: str(),
1170
+ alias: nullableStr(),
1171
+ status: str("draft or published"),
1172
+ format: str("html or blocks"),
1173
+ subject: str("Default subject; empty for a layout-only template"),
1174
+ preheader: nullableStr(),
1175
+ html: nullableStr(),
1176
+ text: nullableStr(),
1177
+ design: { type: ["object", "null"], description: "Block design, for blocks templates" },
1178
+ variables: { type: ["array", "null"], items: { type: "string" } },
1179
+ unsubscribe_enabled: bool(),
1180
+ category: str("transactional, product, or newsletter"),
1181
+ published_version_id: nullableStr(),
1182
+ published_at: nullableStr("ISO 8601"),
1183
+ created_at: str("ISO 8601"),
1184
+ updated_at: str("ISO 8601"),
1185
+ warnings: listOf(object({ code: str(), message: str(), detail: str() }), "Content problems found in the template")
1186
+ },
1187
+ ["id"]
1188
+ );
1189
+ var deliverabilityBucket = {
1190
+ total_emails: num(),
1191
+ attempted: num(),
1192
+ sent: num(),
1193
+ delivered: num(),
1194
+ bounced: num(),
1195
+ complained: num(),
1196
+ failed: num(),
1197
+ suppressed: num(),
1198
+ scheduled: num(),
1199
+ opened: num(),
1200
+ clicked: num(),
1201
+ bounce_rate: num(),
1202
+ complaint_rate: num(),
1203
+ delivery_rate: num(),
1204
+ open_rate: num(),
1205
+ click_rate: num()
1206
+ };
1207
+ var okResult = output({ ok: bool() }, ["ok"]);
1208
+ var MAIL_OUTPUT_SCHEMAS = {
1209
+ send_email: sendResult,
1210
+ list_emails: page("emails", email, "Sent emails, newest first"),
1211
+ get_email: output({ email }, ["email"]),
1212
+ cancel_email: sendResult,
1213
+ list_received_emails: page("received_emails", receivedEmail, "Inbound emails, newest first"),
1214
+ get_received_email: output({ received_email: receivedEmail }, ["received_email"]),
1215
+ list_domains: page("domains", domain, "Sending domains"),
1216
+ get_domain: output(domainDnsFields, ["domain", "records"]),
1217
+ create_domain: output(
1218
+ {
1219
+ domain,
1220
+ records: domainDnsFields.records,
1221
+ inbound_records: domainDnsFields.inbound_records,
1222
+ inbound_note: str(),
1223
+ mail_server_dns_synced: bool("True when the domain was registered on the mail server"),
1224
+ mail_server_dns_error: str("Why registering it failed, when it did")
1225
+ },
1226
+ ["domain", "records"]
1227
+ ),
1228
+ apply_domain_dns: output(
1229
+ {
1230
+ provider: str("cloudflare, godaddy, or vercel"),
1231
+ domain: str(),
1232
+ results: listOf(
1233
+ object(
1234
+ {
1235
+ purpose: str(),
1236
+ host: str(),
1237
+ action: str("created, updated, unchanged, or merged"),
1238
+ detail: str()
1239
+ },
1240
+ ["host", "action"]
1241
+ ),
1242
+ "What happened to each record"
1243
+ ),
1244
+ records: listOf(dnsRecord, "The records that were applied")
1245
+ },
1246
+ ["provider", "domain", "results"]
1247
+ ),
1248
+ verify_domain: output(
1249
+ {
1250
+ ...domainDnsFields,
1251
+ verified: bool("True once DNS checks out. A domain whose DNS is not ready comes back as an error instead."),
1252
+ checks: object({ ownership: bool(), spf: bool(), dkim: bool(), dmarc: bool(), return_path: bool() }),
1253
+ auto: bool(),
1254
+ inbound_configured: bool(),
1255
+ inbound_pending: bool(),
1256
+ inbound_error: nullableStr(),
1257
+ mail_server_registered: bool(),
1258
+ signing_ready: bool("True when the mail server can sign mail for the domain"),
1259
+ mail_server_signing: object({
1260
+ dkim_ok: bool(),
1261
+ return_path_ok: bool(),
1262
+ dkim_status: nullableStr(),
1263
+ return_path_status: nullableStr()
1264
+ })
1265
+ },
1266
+ ["domain", "verified"]
1267
+ ),
1268
+ list_webhooks: page("webhooks", webhook, "Webhook endpoints"),
1269
+ create_webhook: output(
1270
+ { webhook, secret: str("Signing secret for verifying deliveries. Shown only once.") },
1271
+ ["webhook", "secret"]
1272
+ ),
1273
+ delete_webhook: okResult,
1274
+ list_suppressions: page("data", suppression, "Suppressed addresses"),
1275
+ add_suppression: output({ suppression }, ["suppression"]),
1276
+ remove_suppression: okResult,
1277
+ list_templates: page("data", template, "Email templates"),
1278
+ get_template: output({ template }, ["template"]),
1279
+ create_template: output({ template }, ["template"]),
1280
+ publish_template: output({ template }, ["template"]),
1281
+ send_test_webhook_event: output(
1282
+ {
1283
+ ok: bool(),
1284
+ payload: { description: "The simulated event payload, as a webhook would receive it" },
1285
+ delivered: bool("True when it was sent to webhook endpoints"),
1286
+ deliveries: listOf(
1287
+ object({
1288
+ endpoint_id: str(),
1289
+ url: str(),
1290
+ success: bool(),
1291
+ status: num("HTTP status the endpoint returned"),
1292
+ error: str()
1293
+ })
1294
+ )
1295
+ },
1296
+ ["ok"]
1297
+ ),
1298
+ get_deliverability: output(
1299
+ {
1300
+ window: str("7d or 30d"),
1301
+ window_start: str("ISO 8601"),
1302
+ generated_at: str("ISO 8601"),
1303
+ summary: object(deliverabilityBucket, [], "Counts and rates across all sends in the window"),
1304
+ by_domain: listOf(
1305
+ object(
1306
+ {
1307
+ domain: str(),
1308
+ ...deliverabilityBucket,
1309
+ mail_server_relay: object(
1310
+ {
1311
+ window_days: num(),
1312
+ total_messages: num(),
1313
+ sent: num(),
1314
+ bounced: num(),
1315
+ failed: num(),
1316
+ delayed: num()
1317
+ },
1318
+ [],
1319
+ "Relay counts from the mail server, for the busiest domains"
1320
+ )
1321
+ },
1322
+ ["domain"]
1323
+ ),
1324
+ "The same, per sending domain"
1325
+ ),
1326
+ top_bounce_reasons: listOf(object({ reason: str(), count: num() })),
1327
+ notes: strList("How to read the numbers")
1328
+ },
1329
+ ["window", "summary"]
1330
+ )
1331
+ };
1332
+ var approvalCard = object(
1333
+ {
1334
+ messageId: str("Pass to ranla_approve or ranla_reject"),
1335
+ summary: str("What it will do"),
1336
+ detail: str()
1337
+ },
1338
+ ["messageId"]
1339
+ );
1340
+ var connectCard = object(
1341
+ {
1342
+ messageId: str(),
1343
+ slot: str("The account to connect"),
1344
+ summary: str(),
1345
+ path: str("Where in the app to connect it"),
1346
+ detail: str()
1347
+ },
1348
+ ["messageId"]
1349
+ );
1350
+ var turnFields = {
1351
+ text: str("Ranla's reply"),
1352
+ tools: listOf(
1353
+ object({ toolName: str(), label: str("What the step did"), isError: bool() }),
1354
+ "Steps Ranla took in the turn"
1355
+ ),
1356
+ artifacts: listOf(
1357
+ object({ type: str("draft, plan, document\u2026"), id: str(), label: str() }, ["type", "id"]),
1358
+ "What Ranla made or opened, by id"
1359
+ ),
1360
+ pendingApproval: { ...approvalCard, description: "The step waiting on ranla_approve. It does nothing until approved." },
1361
+ pendingApprovals: listOf(approvalCard, "Every step waiting, when there is more than one"),
1362
+ connectRequired: { ...connectCard, description: "An account the founder must connect in the app before Ranla can go on" },
1363
+ connectsRequired: listOf(connectCard, "Every account to connect, when there is more than one"),
1364
+ error: str("Set when the turn failed")
1365
+ };
1366
+ var thread = object(
1367
+ {
1368
+ id: str("Thread id"),
1369
+ title: str(),
1370
+ scope: nullableStr("Desk the thread belongs to"),
1371
+ origin: str("Where the thread was started"),
1372
+ lastMessageAt: nullableStr("ISO 8601")
1373
+ },
1374
+ ["id"]
1375
+ );
1376
+ var RANLA_OUTPUT_SCHEMAS = {
1377
+ ranla_list_threads: output(
1378
+ {
1379
+ threads: listOf(
1380
+ {
1381
+ ...thread,
1382
+ properties: {
1383
+ ...thread.properties,
1384
+ preview: str("First line of the latest message")
1385
+ }
1386
+ },
1387
+ "Most recent first"
1388
+ ),
1389
+ nextBefore: nullableStr("Pass as before for older threads; null on the last page")
1390
+ },
1391
+ ["threads"]
1392
+ ),
1393
+ ranla_get_thread: output(
1394
+ {
1395
+ thread: {
1396
+ ...thread,
1397
+ properties: {
1398
+ ...thread.properties,
1399
+ running: bool("True while Ranla is still working on a turn")
1400
+ }
1401
+ },
1402
+ messages: listOf(
1403
+ object(
1404
+ {
1405
+ id: str("Message id; for a pending approval, the messageId ranla_approve takes"),
1406
+ role: str("user, assistant, or tool"),
1407
+ text: str(),
1408
+ createdAt: nullableStr("ISO 8601"),
1409
+ error: str(),
1410
+ tools: listOf(object({ name: str(), label: str(), isError: bool() })),
1411
+ artifact: object({ type: str(), id: str() }, ["type"]),
1412
+ approval: object(
1413
+ {
1414
+ status: str("pending, approved, or rejected"),
1415
+ summary: str(),
1416
+ detail: nullableStr(),
1417
+ kind: nullableStr(),
1418
+ reviewUrl: str()
1419
+ },
1420
+ ["status"]
1421
+ ),
1422
+ connect: object({ slot: str(), summary: str(), detail: nullableStr(), url: nullableStr() })
1423
+ },
1424
+ ["id", "role"]
1425
+ ),
1426
+ "Oldest first within the page"
1427
+ ),
1428
+ earlierBefore: nullableStr("Pass as before for the page of older messages; null when there are none"),
1429
+ earlierCount: num("Messages older than this page")
1430
+ },
1431
+ ["thread", "messages"]
1432
+ ),
1433
+ ranla_open_thread: output(
1434
+ {
1435
+ threadId: str("Pass to ranla_message and ranla_command"),
1436
+ title: nullableStr()
1437
+ },
1438
+ ["threadId"]
1439
+ ),
1440
+ ranla_message: output(
1441
+ {
1442
+ threadId: str("The thread the turn ran in"),
1443
+ ...turnFields,
1444
+ runId: str("The turn's run, while it is followed"),
1445
+ setAsideApprovals: listOf(
1446
+ object({ messageId: str(), summary: str() }, ["messageId"]),
1447
+ "Approvals that were waiting and were set aside by this message"
1448
+ ),
1449
+ running: bool("True when Ranla is still working after 15 minutes; read the answer with ranla_get_thread"),
1450
+ note: str()
1451
+ },
1452
+ ["threadId"]
1453
+ ),
1454
+ ranla_list_approvals: output(
1455
+ {
1456
+ approvals: listOf(
1457
+ object(
1458
+ {
1459
+ messageId: str("Pass to ranla_approve or ranla_reject"),
1460
+ threadId: str(),
1461
+ threadTitle: str(),
1462
+ toolName: nullableStr(),
1463
+ kind: nullableStr(),
1464
+ summary: str("What it will do"),
1465
+ detail: nullableStr(),
1466
+ createdAt: str("ISO 8601"),
1467
+ url: str("Where to review it in the app"),
1468
+ area: str("paid or seo, for approvals reviewed in their own view"),
1469
+ actionId: str(),
1470
+ next: str()
1471
+ },
1472
+ ["messageId"]
1473
+ ),
1474
+ "Pending approvals across threads"
1475
+ )
1476
+ },
1477
+ ["approvals"]
1478
+ ),
1479
+ ranla_approve: output(turnFields, [], "The turn the approval resumed"),
1480
+ ranla_reject: output(turnFields, [], "The turn the rejection resumed"),
1481
+ ranla_command: output(
1482
+ {
1483
+ result: openObject(
1484
+ "What the command returned: content (any JSON, shaped by the command), plus steps and artifact when it has them"
1485
+ ),
1486
+ pendingApproval: object(
1487
+ {
1488
+ messageId: str("Pass to ranla_approve or ranla_reject"),
1489
+ summary: str("What it will do"),
1490
+ threadId: str(),
1491
+ url: str("Where to review it in the app")
1492
+ },
1493
+ ["messageId"],
1494
+ "Set when the command needs sign-off. It does nothing until ranla_approve."
1495
+ ),
1496
+ next: str("What to do with the pending approval"),
1497
+ noun: str("For help <noun>: the noun"),
1498
+ commands: openObject("For help <noun>: each verb's input schema")
1499
+ },
1500
+ [],
1501
+ "A command's response. Each command returns its own result; one that needs sign-off also returns pendingApproval."
1502
+ )
1503
+ };
1504
+ var CHATGPT_COMMAND_OUTPUT_SCHEMA = output(
1505
+ {
1506
+ content: openObject("What the command returned. Its fields depend on the command; read them before quoting a number.")
1507
+ },
1508
+ [],
1509
+ "A Ranla command's result."
1510
+ );
1511
+ var MCP_OUTPUT_SCHEMAS = {
1512
+ ...MAIL_OUTPUT_SCHEMAS,
1513
+ ...RANLA_OUTPUT_SCHEMAS
1514
+ };
1515
+ function mcpOutputSchema(name) {
1516
+ return MCP_OUTPUT_SCHEMAS[name];
1517
+ }
1518
+
1519
+ // src/tools.ts
1520
+ function textResult(value, isError = false) {
1521
+ const content = [
1522
+ { type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }
1523
+ ];
1524
+ if (isError) return { content, isError: true };
1525
+ const structuredContent = value && typeof value === "object" && !Array.isArray(value) ? value : { result: value };
1526
+ return { content, structuredContent };
1527
+ }
791
1528
  function optString2(args, key) {
792
1529
  if (args[key] == null) return void 0;
793
1530
  const value = String(args[key]).trim();
@@ -865,7 +1602,7 @@ async function callMcpTool(client, name, args, options = {}) {
865
1602
  try {
866
1603
  if (isArcMcpToolName(name)) {
867
1604
  const apiKey = options.apiKey ?? resolveApiKey();
868
- const result = await callArcMcpTool(apiKey, name, args, options.baseUrl);
1605
+ const result = await callArcMcpTool(apiKey, name, args, options.baseUrl, { askOnly: options.askOnly });
869
1606
  return textResult(result);
870
1607
  }
871
1608
  switch (name) {
@@ -942,13 +1679,13 @@ async function callMcpTool(client, name, args, options = {}) {
942
1679
  return textResult(await client.receivedEmails.get(id));
943
1680
  }
944
1681
  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));
1682
+ const domain2 = optString2(args, "domain");
1683
+ if (!domain2) return textResult("Missing required argument: domain", true);
1684
+ return textResult(await client.domains.get(domain2));
948
1685
  }
949
1686
  case "apply_domain_dns": {
950
- const domain = String(args.domain ?? "").trim();
951
- if (!domain) {
1687
+ const domain2 = String(args.domain ?? "").trim();
1688
+ if (!domain2) {
952
1689
  return textResult("Missing required argument: domain", true);
953
1690
  }
954
1691
  const providerRaw = typeof args.provider === "string" ? args.provider.trim().toLowerCase() : "";
@@ -958,17 +1695,13 @@ async function callMcpTool(client, name, args, options = {}) {
958
1695
  true
959
1696
  );
960
1697
  }
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
- });
1698
+ const result = await client.domains.apply(domain2, { provider: providerRaw });
966
1699
  return textResult(result);
967
1700
  }
968
1701
  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);
1702
+ const domain2 = optString2(args, "domain");
1703
+ if (!domain2) return textResult("Missing required argument: domain", true);
1704
+ const result = await client.domains.verify(domain2);
972
1705
  return textResult(result);
973
1706
  }
974
1707
  case "list_domains": {
@@ -1029,11 +1762,11 @@ async function callMcpTool(client, name, args, options = {}) {
1029
1762
  );
1030
1763
  }
1031
1764
  case "add_suppression": {
1032
- const email = optString2(args, "email");
1033
- if (!email) return textResult("Missing required argument: email", true);
1765
+ const email2 = optString2(args, "email");
1766
+ if (!email2) return textResult("Missing required argument: email", true);
1034
1767
  return textResult(
1035
1768
  await client.suppressions.create({
1036
- email,
1769
+ email: email2,
1037
1770
  reason: optString2(args, "reason")
1038
1771
  })
1039
1772
  );
@@ -1137,8 +1870,8 @@ var MCP_TOOLS = [
1137
1870
  bcc: recipientProp("Bcc recipients"),
1138
1871
  reply_to: recipientProp("Reply-To"),
1139
1872
  subject: { type: "string", description: "Optional when using a published template" },
1140
- html: { type: "string" },
1141
- text: { type: "string" },
1873
+ html: { type: "string", description: "HTML body. Send html, text or both, unless you use a template" },
1874
+ text: { type: "string", description: "Plain-text body. Sent alongside html, or on its own" },
1142
1875
  template_id: { type: "string", description: "Published template id" },
1143
1876
  template_alias: { type: "string", description: "Published template alias" },
1144
1877
  variables: {
@@ -1259,15 +1992,15 @@ var MCP_TOOLS = [
1259
1992
  },
1260
1993
  {
1261
1994
  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.",
1995
+ description: "Create the sending domain's DNS records at your DNS provider (Cloudflare, GoDaddy, or Vercel), using the connection stored in the dashboard. Connect the provider in the dashboard first; registrar credentials are never passed through here.",
1263
1996
  inputSchema: {
1264
1997
  type: "object",
1265
1998
  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)"
1999
+ domain: { type: "string", description: "Domain id or name (example.com)" },
2000
+ provider: {
2001
+ type: "string",
2002
+ enum: ["cloudflare", "godaddy", "vercel"],
2003
+ description: "DNS provider that hosts the domain"
1271
2004
  }
1272
2005
  },
1273
2006
  required: ["domain", "provider"]
@@ -1279,7 +2012,7 @@ var MCP_TOOLS = [
1279
2012
  inputSchema: {
1280
2013
  type: "object",
1281
2014
  properties: {
1282
- domain: { type: "string" }
2015
+ domain: { type: "string", description: "Domain id or name (example.com)" }
1283
2016
  },
1284
2017
  required: ["domain"]
1285
2018
  }
@@ -1336,8 +2069,8 @@ var MCP_TOOLS = [
1336
2069
  inputSchema: {
1337
2070
  type: "object",
1338
2071
  properties: {
1339
- email: { type: "string" },
1340
- reason: { type: "string" }
2072
+ email: { type: "string", description: "Address to stop sending to" },
2073
+ reason: { type: "string", description: 'Optional note on why, e.g. "asked to stop"' }
1341
2074
  },
1342
2075
  required: ["email"]
1343
2076
  }
@@ -1360,7 +2093,7 @@ var MCP_TOOLS = [
1360
2093
  type: "object",
1361
2094
  properties: {
1362
2095
  ...paginationProps,
1363
- status: { type: "string", enum: ["draft", "published"] }
2096
+ status: { type: "string", enum: ["draft", "published"], description: "Only drafts or only published templates" }
1364
2097
  }
1365
2098
  }
1366
2099
  },
@@ -1381,12 +2114,16 @@ var MCP_TOOLS = [
1381
2114
  inputSchema: {
1382
2115
  type: "object",
1383
2116
  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" }
2117
+ name: { type: "string", description: "Template name shown in the dashboard" },
2118
+ subject: { type: "string", description: "Default subject line. Variables like {{name}} are allowed" },
2119
+ format: {
2120
+ type: "string",
2121
+ enum: ["html", "blocks"],
2122
+ description: "html for a raw HTML body, or blocks for the dashboard block editor"
2123
+ },
2124
+ html: { type: "string", description: "HTML body (html format). Variables like {{name}} are allowed" },
2125
+ text: { type: "string", description: "Plain-text body" },
2126
+ alias: { type: "string", description: "Stable name to send with (template_alias), e.g. password-reset" }
1390
2127
  },
1391
2128
  required: ["name"]
1392
2129
  }
@@ -1397,8 +2134,8 @@ var MCP_TOOLS = [
1397
2134
  inputSchema: {
1398
2135
  type: "object",
1399
2136
  properties: {
1400
- id: { type: "string" },
1401
- alias: { type: "string" },
2137
+ id: { type: "string", description: "Template id" },
2138
+ alias: { type: "string", description: "Template alias (alternative to id)" },
1402
2139
  label: { type: "string", description: "Optional name for this version in the template history" }
1403
2140
  }
1404
2141
  }
@@ -1430,7 +2167,7 @@ var MCP_TOOLS = [
1430
2167
  inputSchema: {
1431
2168
  type: "object",
1432
2169
  properties: {
1433
- window: { type: "string", enum: ["7d", "30d"] }
2170
+ window: { type: "string", enum: ["7d", "30d"], description: "Look-back window. Default 30d" }
1434
2171
  }
1435
2172
  }
1436
2173
  }
@@ -1453,48 +2190,76 @@ function resolveApiKey() {
1453
2190
  // src/server.ts
1454
2191
  var MCP_SERVER_VERSION = version;
1455
2192
  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.",
2193
+ "Ranla is an AI growth engineer for a software business: lifecycle email, audiences, campaigns, pages, ads, and replies, plus transactional email from the same workspace.",
2194
+ 'For growth work, or anything that needs judgement or research, send the request to Ranla with ranla_message and show its reply. Use ranla_command when the user names one specific step, such as "campaign list".',
2195
+ "Anything that sends, spends, or publishes comes back as pendingApproval and does nothing yet. Show it to the user and call ranla_approve or ranla_reject only after they answer.",
1459
2196
  "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
2197
  ].join("\n\n");
1461
2198
  var MCP_TOOL_SECURITY_SCHEMES = [{ type: "oauth2", scopes: ["full"] }];
2199
+ function listEntry(tool, outputSchema) {
2200
+ const annotations = mcpToolAnnotations(tool);
2201
+ return {
2202
+ name: tool.name,
2203
+ title: annotations.title,
2204
+ description: tool.description,
2205
+ inputSchema: tool.inputSchema,
2206
+ ...outputSchema ? { outputSchema } : {},
2207
+ annotations,
2208
+ securitySchemes: MCP_TOOL_SECURITY_SCHEMES,
2209
+ _meta: { securitySchemes: MCP_TOOL_SECURITY_SCHEMES }
2210
+ };
2211
+ }
2212
+ async function listMcpTools(apiKey, baseUrl, profile = "full") {
2213
+ const tools = [...MCP_TOOLS, ...await loadArcMcpTools(apiKey, baseUrl)];
2214
+ if (profile === "full") return tools.map((tool) => listEntry(tool, mcpOutputSchema(tool.name)));
2215
+ const kept = tools.filter((tool) => !CHATGPT_EXCLUDED_TOOLS.has(tool.name)).map((tool) => listEntry(tool, mcpOutputSchema(tool.name)));
2216
+ const named = CHATGPT_COMMAND_TOOLS.map(
2217
+ (tool) => listEntry({ ...tool, readOnly: true }, CHATGPT_COMMAND_OUTPUT_SCHEMA)
2218
+ );
2219
+ return forChatGpt([...named, ...kept]);
2220
+ }
1462
2221
  function withUpdateNotice(result, notice) {
1463
2222
  return { ...result, content: [...result.content, { type: "text", text: notice }] };
1464
2223
  }
2224
+ async function callChatGptCommandTool(apiKey, name, args, baseUrl) {
2225
+ try {
2226
+ const body = await runRanlaCommand(apiKey, chatGptCommandFor(name), args, baseUrl);
2227
+ const result = body.result;
2228
+ return textResult(result && typeof result === "object" && !Array.isArray(result) ? result : body);
2229
+ } catch (error) {
2230
+ return textResult(error instanceof Error ? error.message : String(error), true);
2231
+ }
2232
+ }
1465
2233
  function createMcpServer(apiKey, baseUrl, options = {}) {
1466
2234
  const client = new SuperSendTX(apiKey, {
1467
2235
  ...baseUrl ? { baseUrl } : {},
1468
2236
  fetch: fetchWithUserAgent(mcpUserAgent())
1469
2237
  });
2238
+ const profile = options.profile ?? "full";
1470
2239
  const knownUpdate = options.updateCheck?.result();
1471
- const instructions = knownUpdate ? `${MCP_SERVER_INSTRUCTIONS}
2240
+ const baseInstructions = profile === "chatgpt" ? CHATGPT_SERVER_INSTRUCTIONS : MCP_SERVER_INSTRUCTIONS;
2241
+ const instructions = knownUpdate ? `${baseInstructions}
1472
2242
 
1473
- ${formatMcpAgentUpdateNotice(knownUpdate)}` : MCP_SERVER_INSTRUCTIONS;
2243
+ ${formatMcpAgentUpdateNotice(knownUpdate)}` : baseInstructions;
1474
2244
  const server = new Server(
1475
2245
  { name: mcpServerName(), version: MCP_SERVER_VERSION },
1476
2246
  { capabilities: { tools: {} }, instructions }
1477
2247
  );
1478
2248
  let updateNoticeSent = false;
1479
2249
  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
- })
2250
+ tools: await listMcpTools(apiKey, baseUrl, profile)
1492
2251
  }));
1493
2252
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1494
- const result = await callMcpTool(client, request.params.name, request.params.arguments ?? {}, {
1495
- apiKey,
1496
- baseUrl
1497
- });
2253
+ const name = currentToolName(request.params.name);
2254
+ const args = request.params.arguments ?? {};
2255
+ let result;
2256
+ if (profile === "chatgpt" && CHATGPT_EXCLUDED_TOOLS.has(name)) {
2257
+ result = textResult(`${name} isn't available here. Ask Ranla with ranla_message instead.`, true);
2258
+ } else if (profile === "chatgpt" && chatGptCommandFor(name)) {
2259
+ result = await callChatGptCommandTool(apiKey, name, args, baseUrl);
2260
+ } else {
2261
+ result = await callMcpTool(client, name, args, { apiKey, baseUrl, askOnly: profile === "chatgpt" });
2262
+ }
1498
2263
  const update = updateNoticeSent ? null : options.updateCheck?.result();
1499
2264
  if (!update) return result;
1500
2265
  updateNoticeSent = true;
@@ -1511,6 +2276,7 @@ async function runStdioServer() {
1511
2276
 
1512
2277
  // src/oauth-metadata.ts
1513
2278
  var MCP_HTTP_PATH = "/mcp";
2279
+ var CHATGPT_MCP_PATH = "/chatgpt";
1514
2280
  function appOriginForMcpOrigin(mcpOrigin) {
1515
2281
  try {
1516
2282
  const host = new URL(mcpOrigin).hostname.toLowerCase();
@@ -1520,8 +2286,8 @@ function appOriginForMcpOrigin(mcpOrigin) {
1520
2286
  }
1521
2287
  return (process.env.SUPERSENDTX_OAUTH_ISSUER?.trim() || process.env.SUPERSENDTX_APP_URL?.trim() || "https://app.ranla.ai").replace(/\/$/, "");
1522
2288
  }
1523
- function buildProtectedResourceMetadata(origin) {
1524
- const resource = `${origin.replace(/\/$/, "")}${MCP_HTTP_PATH}`;
2289
+ function buildProtectedResourceMetadata(origin, path = MCP_HTTP_PATH) {
2290
+ const resource = `${origin.replace(/\/$/, "")}${path}`;
1525
2291
  const appOrigin = appOriginForMcpOrigin(origin);
1526
2292
  return {
1527
2293
  resource,
@@ -1530,8 +2296,9 @@ function buildProtectedResourceMetadata(origin) {
1530
2296
  bearer_methods_supported: ["header"]
1531
2297
  };
1532
2298
  }
1533
- function buildWwwAuthenticateChallenge(origin) {
1534
- const metadataUrl = `${origin.replace(/\/$/, "")}/.well-known/oauth-protected-resource`;
2299
+ function buildWwwAuthenticateChallenge(origin, path = MCP_HTTP_PATH) {
2300
+ const suffix = path === MCP_HTTP_PATH ? "" : path;
2301
+ const metadataUrl = `${origin.replace(/\/$/, "")}/.well-known/oauth-protected-resource${suffix}`;
1535
2302
  return `Bearer error="invalid_token", error_description="OAuth authentication required", resource_metadata="${metadataUrl}"`;
1536
2303
  }
1537
2304
 
@@ -1631,7 +2398,14 @@ async function handleHttpRequest(req, res, options = {}) {
1631
2398
  });
1632
2399
  return;
1633
2400
  }
1634
- if (url.pathname !== "/mcp" && url.pathname !== "/") {
2401
+ if (url.pathname === `/.well-known/oauth-protected-resource${CHATGPT_MCP_PATH}`) {
2402
+ sendJson(res, 200, buildProtectedResourceMetadata(origin, CHATGPT_MCP_PATH), {
2403
+ "cache-control": "public, max-age=300, s-maxage=3600"
2404
+ });
2405
+ return;
2406
+ }
2407
+ const chatgpt = url.pathname === CHATGPT_MCP_PATH;
2408
+ if (url.pathname !== "/mcp" && url.pathname !== "/" && !chatgpt) {
1635
2409
  sendJson(res, 404, { error: "Not found. MCP endpoint is POST/GET /mcp" });
1636
2410
  return;
1637
2411
  }
@@ -1645,11 +2419,11 @@ async function handleHttpRequest(req, res, options = {}) {
1645
2419
  res,
1646
2420
  401,
1647
2421
  { error: "Missing or invalid Authorization. Use Bearer stx_\u2026 / rnl_\u2026 or complete MCP OAuth." },
1648
- { "www-authenticate": buildWwwAuthenticateChallenge(origin) }
2422
+ { "www-authenticate": buildWwwAuthenticateChallenge(origin, chatgpt ? CHATGPT_MCP_PATH : void 0) }
1649
2423
  );
1650
2424
  return;
1651
2425
  }
1652
- const server = createMcpServer(apiKey, options.baseUrl);
2426
+ const server = createMcpServer(apiKey, options.baseUrl, { profile: chatgpt ? "chatgpt" : "full" });
1653
2427
  const transport = new StreamableHTTPServerTransport({
1654
2428
  sessionIdGenerator: void 0
1655
2429
  });