@fil-technology/appmate-mcp 0.4.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
@@ -76,6 +76,20 @@ staging or self-hosted instances.
76
76
  | `update_report_draft` | Replace the report draft (categorised). |
77
77
  | `publish_report_flow` | Promote the report draft live. |
78
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. |
79
93
 
80
94
  Tools that accept an app reference (`get_app`, `update_cancel_draft`,
81
95
  etc.) accept either the cuid `id` or the human-readable `slug` — use
@@ -91,6 +105,10 @@ whichever you have. The full REST shape is documented at
91
105
  > *"Export the waitlist for `appmate-pro` as CSV and save it to
92
106
  > `~/Downloads/waitlist.csv`."*
93
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
+
94
112
  > *"Compare the published and draft cancel configs for `quakemate` and
95
113
  > tell me what changed."*
96
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 = {
@@ -170,10 +198,7 @@ export const updateWaitlistDraft = {
170
198
  "Server returns { ok:true, warnings: [...] } — check warnings before publishing.",
171
199
  "Common warnings: partial_cta (label without url, or vice versa).",
172
200
  ].join("\n"),
173
- inputSchema: z.object({
174
- appIdOrSlug: z.string().min(1),
175
- config: z.unknown(),
176
- }),
201
+ inputSchema: updateDraftInput,
177
202
  handler: (input, cfg) => apiFetch(cfg, "PUT", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/waitlist`, input.config),
178
203
  };
179
204
  export const publishWaitlistFlow = {
@@ -253,10 +278,7 @@ export const updateFeedbackDraft = {
253
278
  "",
254
279
  "Server returns { ok:true, warnings: [] }. Warning rules will be added later — for now treat any non-empty array as advisory.",
255
280
  ].join("\n"),
256
- inputSchema: z.object({
257
- appIdOrSlug: z.string().min(1),
258
- config: z.unknown(),
259
- }),
281
+ inputSchema: updateDraftInput,
260
282
  handler: (input, cfg) => apiFetch(cfg, "PUT", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/feedback`, input.config),
261
283
  };
262
284
  export const publishFeedbackFlow = {
@@ -318,10 +340,7 @@ export const updateReportDraft = {
318
340
  "",
319
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.",
320
342
  ].join("\n"),
321
- inputSchema: z.object({
322
- appIdOrSlug: z.string().min(1),
323
- config: z.unknown(),
324
- }),
343
+ inputSchema: updateDraftInput,
325
344
  handler: (input, cfg) => apiFetch(cfg, "PUT", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/flows/report`, input.config),
326
345
  };
327
346
  export const publishReportFlow = {
@@ -351,25 +370,264 @@ export const listReportSubmissions = {
351
370
  return apiFetch(cfg, "GET", `/api/v1/apps/${encodeURIComponent(input.appIdOrSlug)}/report/submissions${tail}`);
352
371
  },
353
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
+ };
354
598
  // Registered alphabetically so `list_tools` reads predictably.
355
599
  export const ALL_TOOLS = [
356
600
  createApp,
601
+ exportOnboardingCsv,
602
+ exportReferralsCsv,
357
603
  exportWaitlistCsv,
358
604
  getApp,
359
605
  getCancelFlow,
606
+ getContactFlow,
360
607
  getFeedbackFlow,
608
+ getOnboardingFlow,
609
+ getReferralFlow,
361
610
  getReportFlow,
362
611
  getWaitlistFlow,
363
612
  listApps,
613
+ listContactSubmissions,
364
614
  listFeedbackSubmissions,
615
+ listOnboardingSubmissions,
616
+ listReferrals,
365
617
  listReportSubmissions,
366
618
  listWaitlistSignups,
367
619
  publishCancelFlow,
620
+ publishContactFlow,
368
621
  publishFeedbackFlow,
622
+ publishOnboardingFlow,
623
+ publishReferralFlow,
369
624
  publishReportFlow,
370
625
  publishWaitlistFlow,
371
626
  updateCancelDraft,
627
+ updateContactDraft,
372
628
  updateFeedbackDraft,
629
+ updateOnboardingDraft,
630
+ updateReferralDraft,
373
631
  updateReportDraft,
374
632
  updateWaitlistDraft,
375
633
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fil-technology/appmate-mcp",
3
- "version": "0.4.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": {