@fil-technology/appmate-mcp 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,6 +68,28 @@ staging or self-hosted instances.
68
68
  | `publish_waitlist_flow` | Promote the waitlist draft live. |
69
69
  | `list_waitlist_signups` | Paginated list (cursor + nextCursor). |
70
70
  | `export_waitlist_csv` | Return the full waitlist as a CSV string. |
71
+ | `get_feedback_flow` | Read published + draft feedback config. |
72
+ | `update_feedback_draft` | Replace the feedback draft. |
73
+ | `publish_feedback_flow` | Promote the feedback draft live. |
74
+ | `list_feedback_submissions` | Paginated list of feedback rows (rating + message + email). |
75
+ | `get_report_flow` | Read published + draft report config. |
76
+ | `update_report_draft` | Replace the report draft (categorised). |
77
+ | `publish_report_flow` | Promote the report draft live. |
78
+ | `list_report_submissions` | Paginated, optional `category` filter. |
79
+ | `get_contact_flow` | Read published + draft contact config. |
80
+ | `update_contact_draft` | Replace the contact draft. |
81
+ | `publish_contact_flow` | Promote the contact draft live. |
82
+ | `list_contact_submissions` | Paginated list of contact rows (name + email + message). |
83
+ | `get_onboarding_flow` | Read published + draft onboarding (web-to-app funnel) config. |
84
+ | `update_onboarding_draft` | Replace the onboarding draft (quiz / info / email-capture steps + handoff). |
85
+ | `publish_onboarding_flow` | Promote the onboarding draft live. |
86
+ | `list_onboarding_submissions` | Paginated list of funnel completions (answers + email + claim status). |
87
+ | `export_onboarding_csv` | Return all onboarding completions as a CSV string. |
88
+ | `get_referral_flow` | Read published + draft referral program config. |
89
+ | `update_referral_draft` | Replace the referral draft (rewards, share text, cap, landing). |
90
+ | `publish_referral_flow` | Promote the referral draft live. |
91
+ | `list_referrals` | Paginated referral graph (status, referee, reward flags). |
92
+ | `export_referrals_csv` | Return the full referral graph as a CSV string. |
71
93
 
72
94
  Tools that accept an app reference (`get_app`, `update_cancel_draft`,
73
95
  etc.) accept either the cuid `id` or the human-readable `slug` — use
@@ -83,6 +105,10 @@ whichever you have. The full REST shape is documented at
83
105
  > *"Export the waitlist for `appmate-pro` as CSV and save it to
84
106
  > `~/Downloads/waitlist.csv`."*
85
107
 
108
+ > *"Build a 3-question onboarding funnel for `ledgr` that asks the user's
109
+ > goal, captures their email, and hands off to the App Store, then publish
110
+ > it."*
111
+
86
112
  > *"Compare the published and draft cancel configs for `quakemate` and
87
113
  > tell me what changed."*
88
114
 
package/dist/index.js CHANGED
@@ -124,6 +124,18 @@ function zodToJsonSchema(schema) {
124
124
  if (schema instanceof z.ZodBoolean) {
125
125
  return { type: "boolean" };
126
126
  }
127
+ // z.preprocess / z.transform wrap the real schema in ZodEffects — emit the
128
+ // inner schema's shape so e.g. a preprocessed record still advertises
129
+ // `type: object` to the host.
130
+ if (schema instanceof z.ZodEffects) {
131
+ return zodToJsonSchema(schema._def.schema);
132
+ }
133
+ // A record (open-ended object, e.g. a flow `config`) MUST advertise
134
+ // `type: object` — otherwise hosts may serialize the value to a JSON string
135
+ // and the server rejects it ("expected object, received string").
136
+ if (schema instanceof z.ZodRecord) {
137
+ return { type: "object", additionalProperties: true };
138
+ }
127
139
  if (schema instanceof z.ZodUnknown || schema instanceof z.ZodAny) {
128
140
  return {};
129
141
  }
package/dist/tools.js CHANGED
@@ -1,5 +1,35 @@
1
1
  import { z } from "zod";
2
2
  import { apiFetch, apiFetchText } from "./api-client.js";
3
+ // `config` for the update_*_draft tools is a full flow-config OBJECT. We
4
+ // must advertise it as `type: object` in the JSON schema (see zodToJsonSchema
5
+ // in index.ts) — otherwise an untyped field leads some MCP hosts to serialize
6
+ // it to a JSON string, which the server then rejects with
7
+ // `422: expected object, received string`.
8
+ //
9
+ // Belt-and-braces: we ALSO accept a JSON string and parse it here (via
10
+ // z.preprocess), since a few hosts stringify nested args regardless. Either
11
+ // way the server receives a real object, never `"{...}"`.
12
+ function parseJsonStringConfig(v) {
13
+ if (typeof v === "string") {
14
+ const trimmed = v.trim();
15
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
16
+ try {
17
+ return JSON.parse(trimmed);
18
+ }
19
+ catch {
20
+ // Not valid JSON — leave it; record validation reports a clear error.
21
+ }
22
+ }
23
+ }
24
+ return v;
25
+ }
26
+ // Shared input for every update_*_draft tool: an app reference + the full
27
+ // config object. The inner record makes `config` advertise `type: object`;
28
+ // the preprocess tolerates a stringified body.
29
+ const updateDraftInput = z.object({
30
+ appIdOrSlug: z.string().min(1),
31
+ config: z.preprocess(parseJsonStringConfig, z.record(z.string(), z.unknown())),
32
+ });
3
33
  // ─── Apps ───────────────────────────────────────────────────────────────────
4
34
  export const listApps = {
5
35
  name: "list_apps",
@@ -46,10 +76,11 @@ export const getCancelFlow = {
46
76
  inputSchema: z.object({ appIdOrSlug: z.string().min(1) }),
47
77
  handler: (input, cfg) => apiFetch(cfg, "GET", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/cancel`),
48
78
  };
49
- // Config body is `z.unknown()` so we hand the raw JSON to the server,
50
- // which has the canonical Zod schema. The server returns 422 with paths
51
- // on validation errors AND a `warnings` array on success for soft
52
- // mismatches (e.g. showThanksScreen + a "Contact support" label).
79
+ // `config` is the full flow-config object (advertised as type:object via
80
+ // updateDraftInput so hosts pass structured JSON). The server holds the
81
+ // canonical Zod schema: it returns 422 with paths on validation errors AND a
82
+ // `warnings` array on success for soft mismatches (e.g. showThanksScreen + a
83
+ // "Contact support" label).
53
84
  export const updateCancelDraft = {
54
85
  name: "update_cancel_draft",
55
86
  description: [
@@ -101,10 +132,7 @@ export const updateCancelDraft = {
101
132
  "",
102
133
  "See https://docs.appmate.cloud/ai-agents for the full do/don't guide and examples.",
103
134
  ].join("\n"),
104
- inputSchema: z.object({
105
- appIdOrSlug: z.string().min(1),
106
- config: z.unknown(),
107
- }),
135
+ inputSchema: updateDraftInput,
108
136
  handler: (input, cfg) => apiFetch(cfg, "PUT", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/cancel`, input.config),
109
137
  };
110
138
  export const publishCancelFlow = {
@@ -125,27 +153,52 @@ export const updateWaitlistDraft = {
125
153
  description: [
126
154
  "Replace the draft waitlist config. Body MUST be a full waitlist config object (type: 'waitlist').",
127
155
  "",
128
- "Required shape:",
156
+ "The public URL at signup.appmate.cloud/{slug} renders a FULL landing",
157
+ "page — not just a form. The `hero` block drives the visual treatment;",
158
+ "omit it for the minimal/legacy look.",
159
+ "",
160
+ "Full shape:",
129
161
  " {",
130
162
  " type: 'waitlist',",
131
163
  " intro: {",
132
- " title, subtitle,",
133
- " emailPlaceholder, submitLabel,",
134
- " legal? (small print, optional)",
164
+ " title, // h1 on the landing",
165
+ " subtitle, // lede paragraph",
166
+ " emailPlaceholder, // input placeholder, e.g. 'you@example.com'",
167
+ " submitLabel, // button text, e.g. 'Notify me'",
168
+ " legal? // small print under the form (optional)",
135
169
  " },",
136
170
  " success: {",
137
- " title, body,",
138
- " ctaLabel?, ctaUrl? // both-or-neither: ctaLabel without ctaUrl renders nothing",
139
- " }",
171
+ " title, body, // shown after signup",
172
+ " ctaLabel?, ctaUrl? // both-or-neither partial pair renders nothing",
173
+ " },",
174
+ " hero?: { // ALL fields optional; omit `hero` entirely for minimal",
175
+ " theme?: 'minimal' | 'gradient' | 'dark' | 'side_by_side',",
176
+ " eyebrow?: string, // short chip above title, e.g. 'Coming soon · Q1 2026'",
177
+ " accentColor?: string, // hex '#rrggbb'; tints button + chip + gradient blob",
178
+ " bullets?: [ // 0–5 value-prop cards under the form",
179
+ " { icon?: '✨', title: 'Fast', body?: 'Sub-second responses' }",
180
+ " ],",
181
+ " showCount?: boolean, // renders '{N} on the waitlist' pill (hides if <3 signups)",
182
+ " heroImage?: string, // optional URL of a hero image",
183
+ " },",
184
+ " templateId?: string // analytics tag if seeded from a template",
140
185
  " }",
141
186
  "",
187
+ "Theme picker guide — pick from intent:",
188
+ " - minimal → no brand, conservative B2B. Default.",
189
+ " - gradient → marketing-launch energy. Pastel blobs + accent color.",
190
+ " - dark → premium / product-reveal vibe. Dark hero + accent glow.",
191
+ " - side_by_side → desktop-first 2-column (story left, form right).",
192
+ " Collapses to minimal on phone — fine for landing-pageiness.",
193
+ "",
194
+ "Starter templates available (see /examples?kind=waitlist):",
195
+ " minimal_email_only, feature_tease, launching_soon, pro_upsell,",
196
+ " private_beta, early_access_referral.",
197
+ "",
142
198
  "Server returns { ok:true, warnings: [...] } — check warnings before publishing.",
143
- "Common: partial_cta (label without url, or vice versa).",
199
+ "Common warnings: partial_cta (label without url, or vice versa).",
144
200
  ].join("\n"),
145
- inputSchema: z.object({
146
- appIdOrSlug: z.string().min(1),
147
- config: z.unknown(),
148
- }),
201
+ inputSchema: updateDraftInput,
149
202
  handler: (input, cfg) => apiFetch(cfg, "PUT", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/waitlist`, input.config),
150
203
  };
151
204
  export const publishWaitlistFlow = {
@@ -182,17 +235,399 @@ export const exportWaitlistCsv = {
182
235
  return { csv };
183
236
  },
184
237
  };
238
+ // ─── Feedback flow ──────────────────────────────────────────────────────────
239
+ export const getFeedbackFlow = {
240
+ name: "get_feedback_flow",
241
+ description: "Read the published and draft feedback flow configs for an app. Feedback flows host an open-ended message form (optional 1–5 star rating + optional reply email) at appmate.cloud/feedback/{appSlug}.",
242
+ inputSchema: z.object({ appIdOrSlug: z.string().min(1) }),
243
+ handler: (input, cfg) => apiFetch(cfg, "GET", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/feedback`),
244
+ };
245
+ export const updateFeedbackDraft = {
246
+ name: "update_feedback_draft",
247
+ description: [
248
+ "Replace the draft feedback config. Body MUST be a full feedback config object (type: 'feedback').",
249
+ "",
250
+ "Shape:",
251
+ " {",
252
+ " type: 'feedback',",
253
+ " intro: {",
254
+ " title, subtitle,",
255
+ " messagePlaceholder, // textarea placeholder",
256
+ " submitLabel,",
257
+ " legal? // small print under the form (optional)",
258
+ " },",
259
+ " rating?: { // OPTIONAL 1–5 star widget",
260
+ " enabled: true,",
261
+ " prompt?: 'How would you rate your experience?',",
262
+ " required?: false // when true, blocks submit until picked",
263
+ " },",
264
+ " emailField?: { // OPTIONAL reply-to email field",
265
+ " enabled: true,",
266
+ " placeholder?: 'you@example.com (optional)',",
267
+ " required?: false",
268
+ " },",
269
+ " success: {",
270
+ " title, body,",
271
+ " ctaLabel?, ctaUrl? // both-or-neither follow-up CTA",
272
+ " },",
273
+ " hero?: { // visual treatment, matches waitlist hero",
274
+ " theme?: 'minimal' | 'gradient' | 'dark' | 'side_by_side',",
275
+ " eyebrow?, accentColor?, titleFont?",
276
+ " }",
277
+ " }",
278
+ "",
279
+ "Server returns { ok:true, warnings: [] }. Warning rules will be added later — for now treat any non-empty array as advisory.",
280
+ ].join("\n"),
281
+ inputSchema: updateDraftInput,
282
+ handler: (input, cfg) => apiFetch(cfg, "PUT", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/feedback`, input.config),
283
+ };
284
+ export const publishFeedbackFlow = {
285
+ name: "publish_feedback_flow",
286
+ description: "Promote the draft feedback config to the live published version. Visitors at appmate.cloud/feedback/{appSlug} see the new version immediately.",
287
+ inputSchema: z.object({ appIdOrSlug: z.string().min(1) }),
288
+ handler: (input, cfg) => apiFetch(cfg, "POST", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/feedback/publish`),
289
+ };
290
+ export const listFeedbackSubmissions = {
291
+ name: "list_feedback_submissions",
292
+ description: "Paginated list of feedback submissions for an app. Each row: { id, message, rating, email, source, createdAt }. limit max 200, default 50; pass nextCursor back for the next page.",
293
+ inputSchema: z.object({
294
+ appIdOrSlug: z.string().min(1),
295
+ limit: z.number().int().min(1).max(200).optional(),
296
+ cursor: z.string().optional(),
297
+ }),
298
+ handler: (input, cfg) => {
299
+ const qs = new URLSearchParams();
300
+ if (input.limit !== undefined)
301
+ qs.set("limit", String(input.limit));
302
+ if (input.cursor)
303
+ qs.set("cursor", input.cursor);
304
+ const tail = qs.toString() ? `?${qs.toString()}` : "";
305
+ return apiFetch(cfg, "GET", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/feedback/submissions${tail}`);
306
+ },
307
+ };
308
+ // ─── Report flow ────────────────────────────────────────────────────────────
309
+ export const getReportFlow = {
310
+ name: "get_report_flow",
311
+ description: "Read the published and draft report flow configs for an app. Report flows host a categorised bug/abuse/spam form (required category picker + message + optional reply email) at appmate.cloud/report/{appSlug}.",
312
+ inputSchema: z.object({ appIdOrSlug: z.string().min(1) }),
313
+ handler: (input, cfg) => apiFetch(cfg, "GET", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/report`),
314
+ };
315
+ export const updateReportDraft = {
316
+ name: "update_report_draft",
317
+ description: [
318
+ "Replace the draft report config. Body MUST be a full report config object (type: 'report').",
319
+ "",
320
+ "Shape:",
321
+ " {",
322
+ " type: 'report',",
323
+ " intro: {",
324
+ " title, subtitle,",
325
+ " messagePlaceholder, // textarea placeholder",
326
+ " submitLabel,",
327
+ " legal? // optional small print",
328
+ " },",
329
+ " categories: [ // REQUIRED 1–10 entries",
330
+ " { id: 'bug', label: 'Bug or crash', emoji?: '🐞', hint?: '…' },",
331
+ " { id: 'abuse', label: 'Harassment or abuse', emoji?: '🚫' },",
332
+ " { id: 'spam', label: 'Spam', emoji?: '🧹' },",
333
+ " { id: 'privacy', label: 'Privacy concern', emoji?: '🔒' },",
334
+ " { id: 'other', label: 'Something else', emoji?: '💬' }",
335
+ " ],",
336
+ " emailField?: { enabled, placeholder?, required? },",
337
+ " success: { title, body, ctaLabel?, ctaUrl? },",
338
+ " hero?: { theme?, eyebrow?, accentColor?, titleFont? }",
339
+ " }",
340
+ "",
341
+ "Category ids must be snake_case ([a-z][a-z0-9_]*). The public submit endpoint validates posted category against this list — unknown ids return 422.",
342
+ ].join("\n"),
343
+ inputSchema: updateDraftInput,
344
+ handler: (input, cfg) => apiFetch(cfg, "PUT", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/report`, input.config),
345
+ };
346
+ export const publishReportFlow = {
347
+ name: "publish_report_flow",
348
+ description: "Promote the draft report config to the live published version. Visitors at appmate.cloud/report/{appSlug} see the new version immediately.",
349
+ inputSchema: z.object({ appIdOrSlug: z.string().min(1) }),
350
+ handler: (input, cfg) => apiFetch(cfg, "POST", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/report/publish`),
351
+ };
352
+ export const listReportSubmissions = {
353
+ name: "list_report_submissions",
354
+ description: "Paginated list of report submissions for an app. Each row: { id, message, category, email, source, createdAt }. Pass `category` to scope to one bucket (e.g. 'bug') for triage.",
355
+ inputSchema: z.object({
356
+ appIdOrSlug: z.string().min(1),
357
+ limit: z.number().int().min(1).max(200).optional(),
358
+ cursor: z.string().optional(),
359
+ category: z.string().optional(),
360
+ }),
361
+ handler: (input, cfg) => {
362
+ const qs = new URLSearchParams();
363
+ if (input.limit !== undefined)
364
+ qs.set("limit", String(input.limit));
365
+ if (input.cursor)
366
+ qs.set("cursor", input.cursor);
367
+ if (input.category)
368
+ qs.set("category", input.category);
369
+ const tail = qs.toString() ? `?${qs.toString()}` : "";
370
+ return apiFetch(cfg, "GET", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/report/submissions${tail}`);
371
+ },
372
+ };
373
+ // ─── Contact flow ───────────────────────────────────────────────────────────
374
+ export const getContactFlow = {
375
+ name: "get_contact_flow",
376
+ description: "Read the published and draft contact flow configs for an app. Contact flows host a minimal inquiry form (optional name + required/optional email + optional message text) at appmate.cloud/contact/{appSlug}.",
377
+ inputSchema: z.object({ appIdOrSlug: z.string().min(1) }),
378
+ handler: (input, cfg) => apiFetch(cfg, "GET", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/contact`),
379
+ };
380
+ export const updateContactDraft = {
381
+ name: "update_contact_draft",
382
+ description: [
383
+ "Replace the draft contact config. Body MUST be a full contact config object (type: 'contact').",
384
+ "",
385
+ "Shape:",
386
+ " {",
387
+ " type: 'contact',",
388
+ " intro: {",
389
+ " title, subtitle,",
390
+ " submitLabel,",
391
+ " legal? // optional small print under the form",
392
+ " },",
393
+ " nameField?: { // OPTIONAL name widget",
394
+ " enabled: boolean,",
395
+ " placeholder?: 'Your name',",
396
+ " required?: boolean",
397
+ " },",
398
+ " emailField?: { // OPTIONAL/REQUIRED email input",
399
+ " enabled: boolean,",
400
+ " placeholder?: 'you@example.com',",
401
+ " required?: boolean",
402
+ " },",
403
+ " messageField?: { // OPTIONAL message textarea",
404
+ " enabled: boolean,",
405
+ " placeholder?: 'What is on your mind?',",
406
+ " required?: boolean",
407
+ " },",
408
+ " success: {",
409
+ " title, body,",
410
+ " ctaLabel?, ctaUrl? // optional follow-up button pair",
411
+ " },",
412
+ " hero?: { // dynamic landing theme config",
413
+ " theme?: 'minimal' | 'gradient' | 'dark' | 'side_by_side',",
414
+ " eyebrow?, accentColor?, titleFont?",
415
+ " }",
416
+ " }",
417
+ ].join("\n"),
418
+ inputSchema: updateDraftInput,
419
+ handler: (input, cfg) => apiFetch(cfg, "PUT", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/contact`, input.config),
420
+ };
421
+ export const publishContactFlow = {
422
+ name: "publish_contact_flow",
423
+ description: "Promote the draft contact config to the live published version. Visitors at appmate.cloud/contact/{appSlug} see the new version live immediately.",
424
+ inputSchema: z.object({ appIdOrSlug: z.string().min(1) }),
425
+ handler: (input, cfg) => apiFetch(cfg, "POST", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/contact/publish`),
426
+ };
427
+ export const listContactSubmissions = {
428
+ name: "list_contact_submissions",
429
+ description: "Paginated list of contact submissions for an app. Each row: { id, name, email, message, source, country, createdAt }. limit max 200, default 50; pass nextCursor back for next page.",
430
+ inputSchema: z.object({
431
+ appIdOrSlug: z.string().min(1),
432
+ limit: z.number().int().min(1).max(200).optional(),
433
+ cursor: z.string().optional(),
434
+ }),
435
+ handler: (input, cfg) => {
436
+ const qs = new URLSearchParams();
437
+ if (input.limit !== undefined)
438
+ qs.set("limit", String(input.limit));
439
+ if (input.cursor)
440
+ qs.set("cursor", input.cursor);
441
+ const tail = qs.toString() ? `?${qs.toString()}` : "";
442
+ return apiFetch(cfg, "GET", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/contact/submissions${tail}`);
443
+ },
444
+ };
445
+ // ─── Onboarding flow (web-to-app funnel) ─────────────────────────────────────
446
+ export const getOnboardingFlow = {
447
+ name: "get_onboarding_flow",
448
+ description: "Read the published and draft onboarding flow configs for an app. Onboarding flows are web-to-app funnels (intro → quiz/info/email-capture steps → App Store handoff) hosted at appmate.cloud/onboarding/{appSlug}. Answers + email are captured server-side; the iOS SDK recovers them on first launch via a claim token.",
449
+ inputSchema: z.object({ appIdOrSlug: z.string().min(1) }),
450
+ handler: (input, cfg) => apiFetch(cfg, "GET", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/onboarding`),
451
+ };
452
+ export const updateOnboardingDraft = {
453
+ name: "update_onboarding_draft",
454
+ description: [
455
+ "Replace the draft onboarding config. Body MUST be a full onboarding config object (type: 'onboarding').",
456
+ "",
457
+ "Shape:",
458
+ " {",
459
+ " type: 'onboarding',",
460
+ " intro: { title, subtitle, startLabel, eyebrow? },",
461
+ " steps: [ // REQUIRED 1–20, ordered",
462
+ " // question step — pick one or several options",
463
+ " {",
464
+ " kind: 'question', id: 'goal',",
465
+ " prompt: 'What brings you here?', subtitle?: '…',",
466
+ " selectMode?: 'single' | 'multi', // omit → single",
467
+ " autoAdvance?: true, // single only: tap advances",
468
+ " required?: true,",
469
+ " options: [ { id: 'save_time', label: 'Save time', emoji?: '⚡' } ]",
470
+ " },",
471
+ " // info step — copy/image screen, no input",
472
+ " { kind: 'info', id: 'value', title: '…', body: '…', imageUrl?, continueLabel? },",
473
+ " // email_capture step — the lead-capture moment",
474
+ " {",
475
+ " kind: 'email_capture', id: 'email',",
476
+ " title: '…', subtitle?, placeholder?, submitLabel?,",
477
+ " required?: true, // omit → required",
478
+ " legal?: '…'",
479
+ " }",
480
+ " ],",
481
+ " handoff: {",
482
+ " title, body, ctaLabel,",
483
+ " appStoreUrl?, // App Store / TestFlight URL; omit → no button",
484
+ " legal?",
485
+ " },",
486
+ " hero?: { theme?, eyebrow?, accentColor?, titleFont? }",
487
+ " }",
488
+ "",
489
+ "Step + option ids must be snake_case ([a-z][a-z0-9_]*) and unique. The server returns { ok:true, warnings:[...] } — warnings flag a missing email_capture step, a handoff with no appStoreUrl, duplicate ids, etc. Treat warnings as advisory.",
490
+ ].join("\n"),
491
+ inputSchema: updateDraftInput,
492
+ handler: (input, cfg) => apiFetch(cfg, "PUT", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/onboarding`, input.config),
493
+ };
494
+ export const publishOnboardingFlow = {
495
+ name: "publish_onboarding_flow",
496
+ description: "Promote the draft onboarding config to the live published version. Visitors at appmate.cloud/onboarding/{appSlug} see the new funnel immediately.",
497
+ inputSchema: z.object({ appIdOrSlug: z.string().min(1) }),
498
+ handler: (input, cfg) => apiFetch(cfg, "POST", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/onboarding/publish`),
499
+ };
500
+ export const listOnboardingSubmissions = {
501
+ name: "list_onboarding_submissions",
502
+ description: "Paginated list of completed onboarding funnels for an app. Each row: { id, answers, email, source, claimed, claimedAt, country, createdAt }. `claimed` is true once the install was matched back to the completion. limit max 200, default 50; pass nextCursor back for the next page.",
503
+ inputSchema: z.object({
504
+ appIdOrSlug: z.string().min(1),
505
+ limit: z.number().int().min(1).max(200).optional(),
506
+ cursor: z.string().optional(),
507
+ }),
508
+ handler: (input, cfg) => {
509
+ const qs = new URLSearchParams();
510
+ if (input.limit !== undefined)
511
+ qs.set("limit", String(input.limit));
512
+ if (input.cursor)
513
+ qs.set("cursor", input.cursor);
514
+ const tail = qs.toString() ? `?${qs.toString()}` : "";
515
+ return apiFetch(cfg, "GET", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/onboarding/submissions${tail}`);
516
+ },
517
+ };
518
+ export const exportOnboardingCsv = {
519
+ name: "export_onboarding_csv",
520
+ description: "Return all completed onboarding funnels for an app as a CSV string (header row + one row per completion, with email + answers JSON + claim status). Useful for hand-off to a spreadsheet or mail merge.",
521
+ inputSchema: z.object({ appIdOrSlug: z.string().min(1) }),
522
+ handler: async (input, cfg) => {
523
+ const csv = await apiFetchText(cfg, "GET", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/onboarding/submissions.csv`);
524
+ return { csv };
525
+ },
526
+ };
527
+ // ─── Referral flow ───────────────────────────────────────────────────────────
528
+ export const getReferralFlow = {
529
+ name: "get_referral_flow",
530
+ description: "Read the published and draft referral program config for an app. Referral is a share-with-a-friend loop: each user gets a link (appmate.cloud/r/{code}) AND a short human-readable code (e.g. K7Q4-R9XP); a friend who taps the link or types the code, then installs, triggers a reward for both sides. Codes + the referral graph live server-side; the iOS SDK mints links/codes, attributes installs on first launch (clipboard handoff) or via a typed code, and reports rewards owed.",
531
+ inputSchema: z.object({ appIdOrSlug: z.string().min(1) }),
532
+ handler: (input, cfg) => apiFetch(cfg, "GET", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/referral`),
533
+ };
534
+ export const updateReferralDraft = {
535
+ name: "update_referral_draft",
536
+ description: [
537
+ "Replace the draft referral config. Body MUST be a full referral config object (type: 'referral').",
538
+ "",
539
+ "Shape:",
540
+ " {",
541
+ " type: 'referral',",
542
+ " landing: { // the invite page a friend sees at /r/{code}",
543
+ " eyebrow?, title, subtitle, ctaLabel,",
544
+ " appStoreUrl?, // REQUIRED before go-live — the install button target",
545
+ " legal?",
546
+ " },",
547
+ " share: { messageTemplate }, // the referrer's share text; the link is appended automatically",
548
+ " rewards: {",
549
+ " referrerWeeks: 1, // free weeks the referrer earns per installed friend (1–8)",
550
+ " refereeEnabled: true, // also reward the NEW user on install?",
551
+ " refereeWeeks?: 1, // required when refereeEnabled (1–8)",
552
+ " referrerLabel?, refereeLabel? // human copy shown on the landing + returned to the SDK",
553
+ " },",
554
+ " maxRewardsPerReferrer?: 10 // lifetime cap per referrer; 0/omit = unlimited (farming risk)",
555
+ " }",
556
+ "",
557
+ "Reward trigger is the friend's INSTALL (attributed on first launch). The server returns { ok:true, warnings:[...] } — warnings flag a missing/placeholder appStoreUrl, refereeEnabled without refereeWeeks, and an absent cap. Treat warnings as advisory but fix them before publishing.",
558
+ ].join("\n"),
559
+ inputSchema: updateDraftInput,
560
+ handler: (input, cfg) => apiFetch(cfg, "PUT", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/referral`, input.config),
561
+ };
562
+ export const publishReferralFlow = {
563
+ name: "publish_referral_flow",
564
+ description: "Promote the draft referral config to the live published version. New share links + the invite landing use the new config immediately.",
565
+ inputSchema: z.object({ appIdOrSlug: z.string().min(1) }),
566
+ handler: (input, cfg) => apiFetch(cfg, "POST", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/referral/publish`),
567
+ };
568
+ export const listReferrals = {
569
+ name: "list_referrals",
570
+ description: "Paginated list of referrals for an app. Each row: { id, code, referrerUserId, status, source, refereeUserId, attributedAt, referrerRewarded, refereeRewarded, country, createdAt }. status='attributed' means the friend installed; source is 'link' (tapped share link) or 'code' (typed the referrer's code). Optional `status` filter; limit max 200, default 50.",
571
+ inputSchema: z.object({
572
+ appIdOrSlug: z.string().min(1),
573
+ status: z.enum(["pending", "attributed", "expired"]).optional(),
574
+ limit: z.number().int().min(1).max(200).optional(),
575
+ cursor: z.string().optional(),
576
+ }),
577
+ handler: (input, cfg) => {
578
+ const qs = new URLSearchParams();
579
+ if (input.status)
580
+ qs.set("status", input.status);
581
+ if (input.limit !== undefined)
582
+ qs.set("limit", String(input.limit));
583
+ if (input.cursor)
584
+ qs.set("cursor", input.cursor);
585
+ const tail = qs.toString() ? `?${qs.toString()}` : "";
586
+ return apiFetch(cfg, "GET", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/referrals${tail}`);
587
+ },
588
+ };
589
+ export const exportReferralsCsv = {
590
+ name: "export_referrals_csv",
591
+ description: "Return the full referral graph for an app as a CSV string (one row per invite, with code, status, source (link/typed-code), referee, and reward flags).",
592
+ inputSchema: z.object({ appIdOrSlug: z.string().min(1) }),
593
+ handler: async (input, cfg) => {
594
+ const csv = await apiFetchText(cfg, "GET", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/referrals.csv`);
595
+ return { csv };
596
+ },
597
+ };
185
598
  // Registered alphabetically so `list_tools` reads predictably.
186
599
  export const ALL_TOOLS = [
187
600
  createApp,
601
+ exportOnboardingCsv,
602
+ exportReferralsCsv,
188
603
  exportWaitlistCsv,
189
604
  getApp,
190
605
  getCancelFlow,
606
+ getContactFlow,
607
+ getFeedbackFlow,
608
+ getOnboardingFlow,
609
+ getReferralFlow,
610
+ getReportFlow,
191
611
  getWaitlistFlow,
192
612
  listApps,
613
+ listContactSubmissions,
614
+ listFeedbackSubmissions,
615
+ listOnboardingSubmissions,
616
+ listReferrals,
617
+ listReportSubmissions,
193
618
  listWaitlistSignups,
194
619
  publishCancelFlow,
620
+ publishContactFlow,
621
+ publishFeedbackFlow,
622
+ publishOnboardingFlow,
623
+ publishReferralFlow,
624
+ publishReportFlow,
195
625
  publishWaitlistFlow,
196
626
  updateCancelDraft,
627
+ updateContactDraft,
628
+ updateFeedbackDraft,
629
+ updateOnboardingDraft,
630
+ updateReferralDraft,
631
+ updateReportDraft,
197
632
  updateWaitlistDraft,
198
633
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fil-technology/appmate-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Model Context Protocol server for AppMate — lets Claude / Cursor / Codex drive your retention flows via API tokens.",
5
5
  "type": "module",
6
6
  "bin": {