@charisol/plexo-mcp 1.0.10 → 1.0.11

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.
@@ -0,0 +1,194 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WP_MIGRATION_TOOL_NAMES = exports.WP_MIGRATION_TOOL_DEFS = void 0;
4
+ exports.handleWpMigrationToolCall = handleWpMigrationToolCall;
5
+ // WordPress → Plexo migration — same tools, same descriptions as the live connector in
6
+ // plexo-web (lib/mcp/wpMigrationTools.ts), executed through plexo-web's REST API.
7
+ // get_wordpress_migration advances the migration one step per call, like the dashboard.
8
+ exports.WP_MIGRATION_TOOL_DEFS = [
9
+ {
10
+ name: "preview_wordpress_migration",
11
+ description: `Checks a WordPress website and previews what moving it to Plexo would involve — WITHOUT changing anything. Finds WordPress even when it's installed in a subfolder or behind a splash/landing page, and returns page/post/media counts, the theme, and what will happen to each plugin (replaced by a Plexo feature, carried over as-is, needs attention, or not needed). Call this first and show the owner the plugin outcomes — especially anything marked NEEDS_ATTENTION (e.g. payment/donation plugins) — before calling start_wordpress_migration.`,
12
+ inputSchema: {
13
+ type: "object",
14
+ properties: {
15
+ url: { type: "string", description: "The site's public address, e.g. 'example.org' or 'https://example.org/blog'. Any page on the site works." },
16
+ },
17
+ required: ["url"],
18
+ },
19
+ },
20
+ {
21
+ name: "start_wordpress_migration",
22
+ description: `Starts moving a WordPress site to a NEW Plexo site (never overwrites an existing one). Copies every page exactly as it looks (theme, Elementor and other page-builder designs, fonts, animations), every blog post into Plexo Blog, the whole media library, and every SEO tag — keeping every web address identical so search rankings and links survive. Forms (Forminator, Contact Form 7, WPForms, Elementor) are reconnected to Plexo Forms. The WordPress site itself is not changed.
23
+
24
+ Requires the owner's explicit confirmation that they own the site (ownershipConfirmed: true) — ask them, never assume. If their account hasn't accepted Plexo's Acceptable Use Policy yet, the call fails with requiresAupAcceptance; ask them to read /legal/acceptable-use and retry with acceptAcceptableUsePolicy: true only if they agree.
25
+
26
+ Returns templateId + jobId. Then call get_wordpress_migration repeatedly (each call advances the migration) until phase is COMPLETED.`,
27
+ inputSchema: {
28
+ type: "object",
29
+ properties: {
30
+ url: { type: "string", description: "The site's public address (same as preview_wordpress_migration)." },
31
+ ownershipConfirmed: { type: "boolean", description: "true only if the owner confirmed they own the site or have permission to move it." },
32
+ acceptAcceptableUsePolicy: { type: "boolean", description: "true only if the owner agreed to Plexo's Acceptable Use Policy (needed once per account)." },
33
+ },
34
+ required: ["url", "ownershipConfirmed"],
35
+ },
36
+ },
37
+ {
38
+ name: "get_wordpress_migration",
39
+ description: `Advances a WordPress migration by one step and returns its progress — call it repeatedly until phase is COMPLETED (or PAUSED_ERROR, in which case calling again retries from where it stopped). When COMPLETED, returns the report: what was copied, SEO comparison, what happened to each plugin, and a cutover checklist. Items with status "blocked" (e.g. connecting payments for a donation form) must be done before the owner points their domain at Plexo; tell the owner about them plainly. Omit jobId to get the organization's most recent migration.`,
40
+ inputSchema: {
41
+ type: "object",
42
+ properties: {
43
+ templateId: { type: "string", description: "The Plexo site id returned by start_wordpress_migration." },
44
+ jobId: { type: "string", description: "The migration id returned by start_wordpress_migration." },
45
+ },
46
+ },
47
+ },
48
+ {
49
+ name: "connect_wordpress_migrator_plugin",
50
+ description: `Starts copying what WordPress keeps private (past form entries and applications, donation history, drafts, redirect rules, the team list) for a finished migration. Returns a download link for the read-only "Plexo Migrator" WordPress plugin and a connection code. Tell the owner: install and activate the plugin (Plugins → Add New → Upload Plugin), then paste the code in WordPress under Tools → Plexo Migrator. Then call import_wordpress_private_data.`,
51
+ inputSchema: { type: "object", properties: { templateId: { type: "string" }, jobId: { type: "string" } }, required: ["templateId", "jobId"] },
52
+ },
53
+ {
54
+ name: "import_wordpress_private_data",
55
+ description: `Checks the Plexo Migrator plugin connection and copies the next batch of private data. Call repeatedly until stage is "done". If it returns an error message, relay it to the owner (it says exactly what to do, e.g. "paste the code in Tools → Plexo Migrator"). When done, the result lists the team found on WordPress — offer to invite them.`,
56
+ inputSchema: { type: "object", properties: { templateId: { type: "string" }, jobId: { type: "string" } }, required: ["templateId", "jobId"] },
57
+ },
58
+ {
59
+ name: "get_domain_switch_plan",
60
+ description: `For a finished migration: reads the old domain's live DNS and returns exactly how to point it at Plexo — who hosts the DNS (and whether cancelling the WordPress hosting would delete it), the email provider, records to KEEP unchanged (email, verification), records to ADD for Plexo, step-by-step instructions and a zone file. Connect the domain first with publish_existing_landing_page (CUSTOM, both apex and www) so the certificate records are included. Always relay the warnings to the owner.`,
61
+ inputSchema: { type: "object", properties: { templateId: { type: "string" }, jobId: { type: "string" } }, required: ["templateId", "jobId"] },
62
+ },
63
+ {
64
+ name: "verify_domain_switch",
65
+ description: `Checks on the real domain that Plexo now serves every migrated URL, email records are intact, DNS no longer depends on the old host, and nothing in the migration is blocking. Only tell the owner it's safe to cancel WordPress hosting when safeToCancelWordPress is true. Call get_domain_switch_plan first.`,
66
+ inputSchema: { type: "object", properties: { templateId: { type: "string" }, jobId: { type: "string" } }, required: ["templateId", "jobId"] },
67
+ },
68
+ ];
69
+ exports.WP_MIGRATION_TOOL_NAMES = new Set(exports.WP_MIGRATION_TOOL_DEFS.map((t) => t.name));
70
+ const TERMINAL = new Set(["COMPLETED", "FAILED", "CANCELLED"]);
71
+ function ids(args) {
72
+ const templateId = typeof args?.templateId === "string" ? args.templateId.trim() : "";
73
+ const jobId = typeof args?.jobId === "string" ? args.jobId.trim() : "";
74
+ if (!templateId || !jobId)
75
+ throw new Error("templateId and jobId are required (from start_wordpress_migration).");
76
+ return { templateId, jobId };
77
+ }
78
+ function summarizeReport(report) {
79
+ return {
80
+ readyForCutover: report.readyForCutover,
81
+ checklist: report.checklist,
82
+ summary: report.summary,
83
+ seo: report.seo,
84
+ plugins: (report.plugins ?? []).map((p) => ({ name: p.name, outcome: p.outcome, explanation: p.explanation })),
85
+ formsReconnected: (report.forms ?? []).map((f) => ({ page: f.page, name: f.name, fields: f.fields })),
86
+ brokenLinks: (report.brokenLinks ?? []).slice(0, 20),
87
+ failedFiles: (report.failedFiles ?? []).slice(0, 20),
88
+ };
89
+ }
90
+ async function handleWpMigrationToolCall(name, args, client) {
91
+ const base = client.appBaseUrl;
92
+ switch (name) {
93
+ case "preview_wordpress_migration": {
94
+ if (typeof args?.url !== "string" || !args.url.trim())
95
+ throw new Error("url is required.");
96
+ const result = await client.previewWordPressMigration(args.url);
97
+ return { preview: result.preview };
98
+ }
99
+ case "start_wordpress_migration": {
100
+ const result = await client.startWordPressMigration({
101
+ sourceUrl: String(args?.url ?? ""),
102
+ ownershipAttested: args?.ownershipConfirmed === true,
103
+ acceptAup: args?.acceptAcceptableUsePolicy === true ? true : undefined,
104
+ });
105
+ if (!result.ok)
106
+ return { started: false, error: result.error, requiresAupAcceptance: result.requiresAupAcceptance ?? false, existingMigration: result.existing ?? null };
107
+ return {
108
+ started: true,
109
+ templateId: result.templateId,
110
+ jobId: result.jobId,
111
+ dashboardUrl: `${base}/dashboard/migrate/wordpress?site=${result.templateId}&job=${result.jobId}`,
112
+ next: "Call get_wordpress_migration with this templateId and jobId until phase is COMPLETED.",
113
+ };
114
+ }
115
+ case "get_wordpress_migration": {
116
+ let templateId = typeof args?.templateId === "string" ? args.templateId : "";
117
+ let jobId = typeof args?.jobId === "string" ? args.jobId : "";
118
+ if (!templateId || !jobId) {
119
+ const { migrations } = await client.listWordPressMigrations();
120
+ if (!migrations?.length)
121
+ return { found: false, message: "No WordPress migrations yet — start one with start_wordpress_migration." };
122
+ templateId = migrations[0].templateId;
123
+ jobId = migrations[0].id;
124
+ }
125
+ const before = await client.getWordPressMigration(templateId, jobId);
126
+ if (!TERMINAL.has(before.job.phase))
127
+ await client.stepWordPressMigration(templateId, jobId);
128
+ const { job, domains } = await client.getWordPressMigration(templateId, jobId);
129
+ const dashboardUrl = `${base}/dashboard/migrate/wordpress?site=${templateId}&job=${jobId}`;
130
+ if (job.phase === "COMPLETED" && job.report) {
131
+ return {
132
+ phase: job.phase,
133
+ templateId,
134
+ jobId,
135
+ dashboardUrl,
136
+ publishedDomains: (domains ?? []).map((d) => d.domain),
137
+ report: summarizeReport(job.report),
138
+ next: "Use get_domain_switch_plan to move the owner's domain; keep WordPress running until verify_domain_switch says it's safe.",
139
+ };
140
+ }
141
+ return {
142
+ phase: job.phase,
143
+ templateId,
144
+ jobId,
145
+ dashboardUrl,
146
+ counts: job.counts ?? null,
147
+ lastError: job.phase === "PAUSED_ERROR" ? (job.errors ?? []).slice(-1)[0] : undefined,
148
+ next: job.phase === "PAUSED_ERROR" ? "Call get_wordpress_migration again to retry from where it stopped." : "Call get_wordpress_migration again to continue.",
149
+ };
150
+ }
151
+ case "get_domain_switch_plan": {
152
+ const { templateId, jobId } = ids(args);
153
+ const result = await client.getDomainSwitchPlan(templateId, jobId);
154
+ return { ...result.plan, connectedDomains: (result.connected ?? []).map((d) => d.domain) };
155
+ }
156
+ case "verify_domain_switch": {
157
+ const { templateId, jobId } = ids(args);
158
+ return (await client.verifyDomainSwitch(templateId, jobId)).verification;
159
+ }
160
+ case "connect_wordpress_migrator_plugin": {
161
+ const { templateId, jobId } = ids(args);
162
+ const current = await client.getMigratorConnector(templateId, jobId);
163
+ const code = current.connector?.code ?? (await client.createMigratorConnector(templateId, jobId)).connector.code;
164
+ return {
165
+ pluginDownloadUrl: `${base}/api/v1/wp-migration/plugin`,
166
+ connectionCode: code,
167
+ steps: [
168
+ "Download the plugin (sign in to Plexo in the browser first) and install it: Plugins → Add New → Upload Plugin, then Activate.",
169
+ "In WordPress, go to Tools → Plexo Migrator and paste the connection code.",
170
+ "Then call import_wordpress_private_data.",
171
+ ],
172
+ };
173
+ }
174
+ case "import_wordpress_private_data": {
175
+ const { templateId, jobId } = ids(args);
176
+ let { connector } = await client.getMigratorConnector(templateId, jobId);
177
+ if (connector?.status !== "CONNECTED") {
178
+ connector = (await client.getMigratorConnector(templateId, jobId, true)).connector;
179
+ if (connector?.status !== "CONNECTED")
180
+ return { connected: false, message: connector?.error ?? "Call connect_wordpress_migrator_plugin first." };
181
+ }
182
+ const { extras } = await client.importMigratorData(templateId, jobId);
183
+ return {
184
+ connected: true,
185
+ stage: extras.stage,
186
+ counts: extras.counts,
187
+ team: extras.stage === "done" ? extras.team : undefined,
188
+ error: extras.error,
189
+ next: extras.stage === "done" ? "Done. Offer to invite the team (admins as admin, others as editor of this site)." : "Call import_wordpress_private_data again to continue.",
190
+ };
191
+ }
192
+ }
193
+ throw new Error(`Unknown tool: ${name}`);
194
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charisol/plexo-mcp",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "description": "Model Context Protocol (MCP) Server for Plexo - Landing page generation, email templates, publishing, and analytics.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
package/src/blogTools.ts CHANGED
@@ -1,26 +1,28 @@
1
1
  import { PlexoClient } from "./client/plexoClient.js";
2
2
 
3
3
  const BLOG_LAYOUT_EXAMPLE = `
4
+ DESIGN QUALITY applies here exactly as in publish_landing_page's description: pick one small ink/paper/accent palette and a serif-heading + sans-body (+ optional uppercase-mono eyebrow) type system, keep borderRadius near 0, use boxShadow sparingly, and give blog_content a constrained maxWidth (~680-760px) so long-form reading stays comfortable — do not default to generic #ffffff/Inter/fontWeight-800.
5
+
4
6
  EXAMPLE — a single-post layout with a hero image, title, meta line, and content:
5
7
  {
6
8
  "body": {
7
- "style": { "backgroundColor": "#ffffff", "color": "#1e293b", "fontFamily": "Inter, sans-serif", "htmlTitle": "Blog Post" },
9
+ "style": { "backgroundColor": "#FAF7F1", "color": "#1B2430", "fontFamily": "'Inter', sans-serif", "htmlTitle": "Blog Post" },
8
10
  "rows": [
9
11
  { "id": "row-hero", "style": { "paddingTop": "0px" }, "columns": [
10
12
  { "id": "col-hero", "width": "100%", "elements": [
11
13
  { "id": "el-featured", "type": "blog_featured_image", "style": { "width": "100%", "maxHeight": "420px", "objectFit": "cover" }, "attributes": {} }
12
14
  ] }
13
15
  ] },
14
- { "id": "row-meta", "style": { "paddingTop": "32px", "paddingBottom": "8px" }, "columns": [
16
+ { "id": "row-meta", "style": { "paddingTop": "40px", "paddingBottom": "8px", "maxWidth": "760px", "margin": "0 auto" }, "columns": [
15
17
  { "id": "col-title", "width": "100%", "elements": [
16
- { "id": "el-title", "type": "blog_title", "style": { "fontSize": "40px", "fontWeight": "800" }, "attributes": {} },
17
- { "id": "el-authordate", "type": "blog_author", "style": { "fontSize": "14px", "color": "#64748b", "display": "inline-block", "marginRight": "12px" }, "attributes": {} },
18
- { "id": "el-date", "type": "blog_date", "style": { "fontSize": "14px", "color": "#64748b", "display": "inline-block" }, "attributes": {} }
18
+ { "id": "el-title", "type": "blog_title", "style": { "fontFamily": "'Playfair Display', serif", "fontWeight": "600", "fontSize": "40px", "lineHeight": "1.15" }, "attributes": {} },
19
+ { "id": "el-authordate", "type": "blog_author", "style": { "fontFamily": "'IBM Plex Mono', monospace", "fontSize": "12px", "letterSpacing": "0.1em", "textTransform": "uppercase", "color": "#5B6472", "display": "inline-block", "marginRight": "12px", "marginTop": "16px" }, "attributes": {} },
20
+ { "id": "el-date", "type": "blog_date", "style": { "fontFamily": "'IBM Plex Mono', monospace", "fontSize": "12px", "letterSpacing": "0.1em", "textTransform": "uppercase", "color": "#5B6472", "display": "inline-block" }, "attributes": {} }
19
21
  ] }
20
22
  ] },
21
- { "id": "row-body", "style": { "paddingTop": "24px", "paddingBottom": "48px" }, "columns": [
23
+ { "id": "row-body", "style": { "paddingTop": "24px", "paddingBottom": "48px", "maxWidth": "760px", "margin": "0 auto" }, "columns": [
22
24
  { "id": "col-body", "width": "100%", "elements": [
23
- { "id": "el-content", "type": "blog_content", "style": { "fontSize": "17px", "lineHeight": "1.7" }, "attributes": {} },
25
+ { "id": "el-content", "type": "blog_content", "style": { "fontFamily": "'Inter', sans-serif", "fontSize": "17px", "lineHeight": "1.75", "color": "#1B2430" }, "attributes": {} },
24
26
  { "id": "el-comments", "type": "blog_comments", "style": { "marginTop": "48px" }, "attributes": {} }
25
27
  ] }
26
28
  ] }
@@ -31,14 +33,15 @@ EXAMPLE — a single-post layout with a hero image, title, meta line, and conten
31
33
  EXAMPLE — a listing layout with a 3-column post grid:
32
34
  {
33
35
  "body": {
34
- "style": { "backgroundColor": "#ffffff", "color": "#1e293b", "fontFamily": "Inter, sans-serif", "htmlTitle": "Blog" },
36
+ "style": { "backgroundColor": "#FAF7F1", "color": "#1B2430", "fontFamily": "'Inter', sans-serif", "htmlTitle": "Blog" },
35
37
  "rows": [
36
- { "id": "row-heading", "style": { "paddingTop": "48px", "paddingBottom": "16px" }, "columns": [
38
+ { "id": "row-heading", "style": { "paddingTop": "56px", "paddingBottom": "16px" }, "columns": [
37
39
  { "id": "col-heading", "width": "100%", "elements": [
38
- { "id": "el-heading", "type": "heading", "style": { "fontSize": "36px", "fontWeight": "800", "textAlign": "center" }, "attributes": { "text": "Latest Posts" } }
40
+ { "id": "el-eyebrow", "type": "text", "style": { "fontFamily": "'IBM Plex Mono', monospace", "fontSize": "12px", "letterSpacing": "0.16em", "textTransform": "uppercase", "color": "#C4623B", "textAlign": "center" }, "attributes": { "text": "Journal" } },
41
+ { "id": "el-heading", "type": "heading", "style": { "fontFamily": "'Playfair Display', serif", "fontWeight": "600", "fontSize": "36px", "textAlign": "center", "margin": "8px 0 0" }, "attributes": { "text": "Latest Posts" } }
39
42
  ] }
40
43
  ] },
41
- { "id": "row-list", "style": { "paddingBottom": "48px" }, "columns": [
44
+ { "id": "row-list", "style": { "paddingBottom": "56px" }, "columns": [
42
45
  { "id": "col-list", "width": "100%", "elements": [
43
46
  { "id": "el-postlist", "type": "blog_post_list", "style": { "gridColumns": "3" }, "attributes": {} }
44
47
  ] }
@@ -795,4 +795,100 @@ export class PlexoClient {
795
795
  if (!res.ok) throw new Error(data?.error || `Failed to fetch funnel addon slots (Status ${res.status})`);
796
796
  return data;
797
797
  }
798
+
799
+ /** Shared request helper for routes outside the blog/commerce/LMS families (WordPress migration, donations). */
800
+ private async apiFetch(method: string, path: string, body?: unknown, label = "Request"): Promise<any> {
801
+ this.checkAuth();
802
+ const res = await fetch(`${this.baseUrl}${path}`, {
803
+ method,
804
+ headers: this.getHeaders(),
805
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
806
+ });
807
+ const data = (await res.json().catch(() => ({}))) as any;
808
+ if (!res.ok) throw new Error(data?.error || `${label} failed (Status ${res.status})`);
809
+ return data;
810
+ }
811
+
812
+ get appBaseUrl(): string {
813
+ return this.baseUrl;
814
+ }
815
+
816
+ // ── Commerce: bank transfers ────────────────────────────────────────────────
817
+
818
+ async resolveBankTransferOrder(templateId: string, orderId: string, action: "confirm" | "reject", reason?: string): Promise<any> {
819
+ return this.commerceFetch("POST", templateId, `/orders/${encodeURIComponent(orderId)}/bank-transfer`, { action, ...(reason ? { reason } : {}) });
820
+ }
821
+
822
+ // ── Donations (/api/v1/commerce/{templateId}/donations) ─────────────────────
823
+
824
+ async listDonations(templateId: string): Promise<any> {
825
+ return this.commerceFetch("GET", templateId, "/donations");
826
+ }
827
+
828
+ async createDonationCampaign(templateId: string, payload: Record<string, any>): Promise<any> {
829
+ return this.commerceFetch("POST", templateId, "/donations", payload);
830
+ }
831
+
832
+ async updateDonationCampaign(templateId: string, campaignId: string, payload: Record<string, any>): Promise<any> {
833
+ return this.commerceFetch("PATCH", templateId, `/donations/${encodeURIComponent(campaignId)}`, payload);
834
+ }
835
+
836
+ async cancelMonthlyDonation(templateId: string, subscriptionId: string): Promise<any> {
837
+ return this.commerceFetch("DELETE", templateId, `/donations/subscriptions/${encodeURIComponent(subscriptionId)}`);
838
+ }
839
+
840
+ async adoptMonthlyDonors(templateId: string, campaignId: string): Promise<any> {
841
+ return this.commerceFetch("POST", templateId, "/donations/adopt-monthly-donors", { campaignId });
842
+ }
843
+
844
+ // ── WordPress migration (/api/v1/wp-migration) ──────────────────────────────
845
+
846
+ async previewWordPressMigration(sourceUrl: string): Promise<any> {
847
+ return this.apiFetch("POST", "/api/v1/wp-migration/detect", { sourceUrl }, "WordPress check");
848
+ }
849
+
850
+ async startWordPressMigration(params: { sourceUrl: string; ownershipAttested: boolean; acceptAup?: boolean }): Promise<any> {
851
+ this.checkAuth();
852
+ const res = await fetch(`${this.baseUrl}/api/v1/wp-migration`, { method: "POST", headers: this.getHeaders(), body: JSON.stringify(params) });
853
+ const data = (await res.json().catch(() => ({}))) as any;
854
+ // Recoverable outcomes (needs AUP consent, already running) are returned, not thrown, so the assistant can act on them.
855
+ if (!res.ok && !data?.requiresAupAcceptance && !data?.existing) throw new Error(data?.error || `Migration start failed (Status ${res.status})`);
856
+ return { ok: res.ok, ...data };
857
+ }
858
+
859
+ async listWordPressMigrations(): Promise<any> {
860
+ return this.apiFetch("GET", "/api/v1/wp-migration", undefined, "Migration list");
861
+ }
862
+
863
+ private migrationPath(templateId: string, jobId: string): string {
864
+ return `/api/v1/wp-migration/${encodeURIComponent(templateId)}/${encodeURIComponent(jobId)}`;
865
+ }
866
+
867
+ async getWordPressMigration(templateId: string, jobId: string): Promise<any> {
868
+ return this.apiFetch("GET", this.migrationPath(templateId, jobId), undefined, "Migration status");
869
+ }
870
+
871
+ async stepWordPressMigration(templateId: string, jobId: string): Promise<any> {
872
+ return this.apiFetch("POST", `${this.migrationPath(templateId, jobId)}/step`, undefined, "Migration step");
873
+ }
874
+
875
+ async getMigratorConnector(templateId: string, jobId: string, check = false): Promise<any> {
876
+ return this.apiFetch("GET", `${this.migrationPath(templateId, jobId)}/connector${check ? "?check=1" : ""}`, undefined, "Plugin connection");
877
+ }
878
+
879
+ async createMigratorConnector(templateId: string, jobId: string): Promise<any> {
880
+ return this.apiFetch("POST", `${this.migrationPath(templateId, jobId)}/connector`, undefined, "Plugin connection");
881
+ }
882
+
883
+ async importMigratorData(templateId: string, jobId: string): Promise<any> {
884
+ return this.apiFetch("POST", `${this.migrationPath(templateId, jobId)}/connector/import`, undefined, "Private data import");
885
+ }
886
+
887
+ async getDomainSwitchPlan(templateId: string, jobId: string): Promise<any> {
888
+ return this.apiFetch("GET", `${this.migrationPath(templateId, jobId)}/cutover`, undefined, "Domain switch plan");
889
+ }
890
+
891
+ async verifyDomainSwitch(templateId: string, jobId: string): Promise<any> {
892
+ return this.apiFetch("POST", `${this.migrationPath(templateId, jobId)}/cutover/verify`, undefined, "Domain check");
893
+ }
798
894
  }
@@ -310,6 +310,20 @@ export const COMMERCE_TOOL_DEFS = [
310
310
  required: ["templateId", "deliveryMethodId"],
311
311
  },
312
312
  },
313
+ {
314
+ name: "resolve_bank_transfer_order",
315
+ description: "Confirms or rejects a bank-transfer order that's waiting for payment (status PENDING, paymentMethod BANK_TRANSFER). action \"confirm\" marks it PAID and runs the normal paid flow (customer confirmation email, digital delivery, booking confirmed) — only use it once the site owner says the money has actually arrived in their account. action \"reject\" cancels it, releases its stock/booking slot, and emails the customer (optional reason is included).",
316
+ inputSchema: {
317
+ type: "object",
318
+ properties: {
319
+ templateId: { type: "string", description: "The site's home page template id." },
320
+ orderId: { type: "string" },
321
+ action: { type: "string", enum: ["confirm", "reject"] },
322
+ reason: { type: "string", description: "Optional note to the customer when rejecting." },
323
+ },
324
+ required: ["templateId", "orderId", "action"],
325
+ },
326
+ },
313
327
  {
314
328
  name: "list_commerce_discounts",
315
329
  description: "Lists a site's discount codes, most recently created first.",
@@ -364,7 +378,7 @@ export const COMMERCE_TOOL_DEFS = [
364
378
  },
365
379
  {
366
380
  name: "list_commerce_customers",
367
- description: "Lists this site's customers directly from its Paystack account (the merchant account that actually processed their payments) — requires Paystack to be configured on this site.",
381
+ description: "Lists everyone who's completed a paid order with this site, aggregated from its own orders (works for Stripe or Paystack, however the customer paid).",
368
382
  inputSchema: {
369
383
  type: "object",
370
384
  properties: {
@@ -485,6 +499,14 @@ export async function handleCommerceToolCall(name: string, args: any, client: Pl
485
499
  await client.deleteCommerceDeliveryMethod(templateId, args.deliveryMethodId);
486
500
  return { success: true, deletedDeliveryMethodId: args.deliveryMethodId };
487
501
 
502
+ case "resolve_bank_transfer_order": {
503
+ const orderId = typeof args.orderId === "string" ? args.orderId.trim() : "";
504
+ if (!orderId) throw new Error("orderId is required.");
505
+ if (args.action !== "confirm" && args.action !== "reject") throw new Error('action must be "confirm" or "reject".');
506
+ const result = await client.resolveBankTransferOrder(templateId, orderId, args.action, typeof args.reason === "string" ? args.reason : undefined);
507
+ return { success: true, order: result.order };
508
+ }
509
+
488
510
  case "list_commerce_discounts":
489
511
  return await client.listCommerceDiscounts(templateId);
490
512
 
@@ -0,0 +1,98 @@
1
+ import { PlexoClient } from "./client/plexoClient.js";
2
+
3
+ // Plexo Donations — same tools, same descriptions as the live connector in plexo-web
4
+ // (lib/mcp/donationTools.ts), executed through plexo-web's REST API. Amounts are in the
5
+ // smallest currency unit, like every other Commerce tool.
6
+
7
+ const CAMPAIGN_FIELDS = {
8
+ name: { type: "string", description: "What donors are giving to, e.g. 'Support our mission'." },
9
+ description: { type: "string", description: "Optional one-line description shown on the form." },
10
+ presetAmounts: {
11
+ type: "array",
12
+ description: "Suggested amounts in the smallest currency unit, optionally named — e.g. [{\"amountMinor\":2500,\"label\":\"Bronze\"},{\"amountMinor\":5000}].",
13
+ items: { type: "object", properties: { amountMinor: { type: "number" }, label: { type: "string" } }, required: ["amountMinor"] },
14
+ },
15
+ defaultPresetIndex: { type: "number", description: "Which suggested amount is pre-selected (0-based)." },
16
+ allowCustomAmount: { type: "boolean", description: "Let donors type their own amount (default true)." },
17
+ minAmountMinor: { type: "number", description: "Smallest custom amount, smallest currency unit (default 100)." },
18
+ allowMonthly: { type: "boolean", description: "Offer monthly giving (default true; needs Stripe or Paystack connected)." },
19
+ defaultFrequency: { type: "string", enum: ["ONCE", "MONTHLY"], description: "Which frequency is pre-selected." },
20
+ goalAmountMinor: { type: ["number", "null"], description: "Optional fundraising goal — shows a progress bar." },
21
+ thankYouMessage: { type: "string", description: "Shown after donating and in the receipt email." },
22
+ buttonLabel: { type: "string", description: "Donate button text (default 'Donate')." },
23
+ };
24
+
25
+ export const DONATION_TOOL_DEFS = [
26
+ {
27
+ name: "list_donations",
28
+ description:
29
+ "Shows a site's donations: totals (raised, donors, monthly donors), every donation form (campaign) with its amounts and progress, monthly donors, and recent gifts. Also says whether payments are connected yet (paymentsReady) — if not, tell the owner to connect Stripe/Paystack or add bank details in Commerce → Settings → Payments.",
30
+ inputSchema: { type: "object", properties: { templateId: { type: "string", description: "The site's home page id (see list_landing_pages)." } }, required: ["templateId"] },
31
+ },
32
+ {
33
+ name: "create_donation_campaign",
34
+ description:
35
+ "Creates a donation form (campaign) for a site — one-time and optional monthly giving — and switches on everything donations need (no shop is created). Returns the campaign id and the embed snippet `<div data-plexo-donation=\"<id>\"></div>`: add that HTML to any page (e.g. via update_landing_page_page or a raw HTML page) to show a working donate form styled to the site.",
36
+ inputSchema: { type: "object", properties: { templateId: { type: "string", description: "The site's home page id." }, ...CAMPAIGN_FIELDS }, required: ["templateId", "name", "presetAmounts"] },
37
+ },
38
+ {
39
+ name: "update_donation_campaign",
40
+ description: "Edits a donation campaign. Only the fields you pass change. Pass active:false to turn a form off (gifts history is kept).",
41
+ inputSchema: {
42
+ type: "object",
43
+ properties: { templateId: { type: "string" }, campaignId: { type: "string" }, active: { type: "boolean" }, ...CAMPAIGN_FIELDS },
44
+ required: ["templateId", "campaignId"],
45
+ },
46
+ },
47
+ {
48
+ name: "cancel_monthly_donation",
49
+ description: "Stops one donor's monthly gift (at Stripe/Paystack too) — they won't be charged again. Only do this when the owner asks; confirm the donor first using list_donations.",
50
+ inputSchema: { type: "object", properties: { templateId: { type: "string" }, subscriptionId: { type: "string", description: "From list_donations' monthlyDonors[].id." } }, required: ["templateId", "subscriptionId"] },
51
+ },
52
+ {
53
+ name: "adopt_monthly_donors",
54
+ description:
55
+ "Brings monthly donors over from a previous website: finds active monthly gifts on the site's connected Stripe/Paystack account that Plexo doesn't know yet and records them under a campaign, so every future charge is receipted and counted. Donors don't have to do anything. Requires the SAME payment account the old site used to be connected.",
56
+ inputSchema: { type: "object", properties: { templateId: { type: "string" }, campaignId: { type: "string" } }, required: ["templateId", "campaignId"] },
57
+ },
58
+ ];
59
+
60
+ export const DONATION_TOOL_NAMES = new Set(DONATION_TOOL_DEFS.map((t) => t.name));
61
+
62
+ export async function handleDonationToolCall(name: string, args: any, client: PlexoClient): Promise<any> {
63
+ const templateId = typeof args?.templateId === "string" ? args.templateId.trim() : "";
64
+ if (!templateId) throw new Error("templateId is required — pass the site's home page id (see list_landing_pages).");
65
+ const { templateId: _t, campaignId, subscriptionId, ...fields } = args ?? {};
66
+ switch (name) {
67
+ case "list_donations":
68
+ return await client.listDonations(templateId);
69
+
70
+ case "create_donation_campaign": {
71
+ const { campaign } = await client.createDonationCampaign(templateId, fields);
72
+ const overview = await client.listDonations(templateId);
73
+ return {
74
+ campaign,
75
+ embedHtml: `<div data-plexo-donation="${campaign.id}"></div>`,
76
+ paymentsReady: overview.paymentsReady,
77
+ next: overview.paymentsReady
78
+ ? "Add embedHtml to the page where donors should give."
79
+ : "Add embedHtml to a page, and ask the owner to connect Stripe/Paystack (or add bank details) in Commerce → Settings → Payments — the form shows 'being set up' until then.",
80
+ };
81
+ }
82
+
83
+ case "update_donation_campaign": {
84
+ if (!campaignId) throw new Error("campaignId is required — use list_donations for ids.");
85
+ const { campaign } = await client.updateDonationCampaign(templateId, String(campaignId), fields);
86
+ return { updated: true, campaign };
87
+ }
88
+
89
+ case "cancel_monthly_donation":
90
+ if (!subscriptionId) throw new Error("subscriptionId is required — use list_donations' monthlyDonors[].id.");
91
+ return await client.cancelMonthlyDonation(templateId, String(subscriptionId));
92
+
93
+ case "adopt_monthly_donors":
94
+ if (!campaignId) throw new Error("campaignId is required — use list_donations for ids.");
95
+ return await client.adoptMonthlyDonors(templateId, String(campaignId));
96
+ }
97
+ throw new Error(`Unknown tool: ${name}`);
98
+ }