@applaunchflow/mcp 0.1.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.
@@ -0,0 +1,230 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { buildTemplateGalleryUrl, } from "./template-previews.js";
4
+ function sendHtml(response, statusCode, html) {
5
+ response.writeHead(statusCode, {
6
+ "Content-Type": "text/html; charset=utf-8",
7
+ "Cache-Control": "no-store",
8
+ });
9
+ response.end(html);
10
+ }
11
+ function successHtml(templateId) {
12
+ return `<!DOCTYPE html>
13
+ <html>
14
+ <head>
15
+ <meta charset="utf-8" />
16
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
17
+ <title>Template Selected</title>
18
+ <style>
19
+ body { font-family: ui-sans-serif, system-ui, sans-serif; background: #f3f0ea; color: #0f172a; display: grid; place-items: center; min-height: 100vh; margin: 0; }
20
+ main { max-width: 520px; background: white; border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 24px; padding: 32px; box-shadow: 0 24px 80px rgba(15, 23, 42, 0.12); text-align: center; }
21
+ h1 { margin: 0 0 12px; font-size: 28px; }
22
+ p { margin: 0; line-height: 1.6; color: #475569; }
23
+ strong { color: #0f172a; }
24
+ </style>
25
+ </head>
26
+ <body>
27
+ <main>
28
+ <h1>Template selected</h1>
29
+ <p>Your choice <strong>${templateId}</strong> was sent back to the MCP flow. You can return to the chat now.</p>
30
+ </main>
31
+ <script>
32
+ setTimeout(() => {
33
+ try { window.close(); } catch {}
34
+ }, 1200);
35
+ </script>
36
+ </body>
37
+ </html>`;
38
+ }
39
+ function errorHtml(message) {
40
+ return `<!DOCTYPE html>
41
+ <html>
42
+ <head>
43
+ <meta charset="utf-8" />
44
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
45
+ <title>Template Selection Error</title>
46
+ <style>
47
+ body { font-family: ui-sans-serif, system-ui, sans-serif; background: #fff5f5; color: #7f1d1d; display: grid; place-items: center; min-height: 100vh; margin: 0; }
48
+ main { max-width: 520px; background: white; border: 1px solid rgba(127, 29, 29, 0.12); border-radius: 24px; padding: 32px; box-shadow: 0 24px 80px rgba(127, 29, 29, 0.08); text-align: center; }
49
+ h1 { margin: 0 0 12px; font-size: 28px; }
50
+ p { margin: 0; line-height: 1.6; color: #991b1b; }
51
+ </style>
52
+ </head>
53
+ <body>
54
+ <main>
55
+ <h1>Selection failed</h1>
56
+ <p>${message}</p>
57
+ </main>
58
+ </body>
59
+ </html>`;
60
+ }
61
+ export class TemplateSelectionCoordinator {
62
+ server = createServer((request, response) => {
63
+ void this.handleRequest(request, response);
64
+ });
65
+ listenPromise = null;
66
+ port = null;
67
+ pendingSelections = new Map();
68
+ async ensureListening() {
69
+ if (this.port !== null) {
70
+ return;
71
+ }
72
+ if (this.listenPromise) {
73
+ await this.listenPromise;
74
+ return;
75
+ }
76
+ this.listenPromise = new Promise((resolve, reject) => {
77
+ this.server.once("error", reject);
78
+ this.server.listen(0, "127.0.0.1", () => {
79
+ const address = this.server.address();
80
+ if (!address || typeof address === "string") {
81
+ reject(new Error("Failed to start template selection server"));
82
+ return;
83
+ }
84
+ this.port = address.port;
85
+ this.server.unref();
86
+ this.server.off("error", reject);
87
+ resolve();
88
+ });
89
+ });
90
+ await this.listenPromise;
91
+ }
92
+ async createSelection(args) {
93
+ await this.ensureListening();
94
+ const selectionId = randomUUID();
95
+ const allowedTemplateIds = new Set(args.templateIds || []);
96
+ let resolveSelection;
97
+ let rejectSelection;
98
+ const promise = new Promise((resolve, reject) => {
99
+ resolveSelection = resolve;
100
+ rejectSelection = reject;
101
+ });
102
+ promise.catch(() => undefined);
103
+ const timeout = setTimeout(() => {
104
+ const pending = this.pendingSelections.get(selectionId);
105
+ if (!pending || pending.settled) {
106
+ return;
107
+ }
108
+ pending.settled = true;
109
+ this.pendingSelections.delete(selectionId);
110
+ pending.reject(new Error("Template selection timed out"));
111
+ }, args.timeoutMs ?? 5 * 60 * 1000);
112
+ this.pendingSelections.set(selectionId, {
113
+ allowedTemplateIds,
114
+ resolve: resolveSelection,
115
+ reject: rejectSelection,
116
+ promise,
117
+ timeout,
118
+ settled: false,
119
+ });
120
+ const callbackUrl = `http://127.0.0.1:${this.port}/template-selection/select?selectionId=${encodeURIComponent(selectionId)}`;
121
+ const galleryUrl = buildTemplateGalleryUrl(args.baseUrl, {
122
+ deviceType: args.deviceType,
123
+ templateIds: args.templateIds,
124
+ selectedTemplateId: args.selectedTemplateId,
125
+ title: args.title,
126
+ returnTo: callbackUrl,
127
+ });
128
+ const cleanup = () => {
129
+ const pending = this.pendingSelections.get(selectionId);
130
+ if (!pending) {
131
+ return;
132
+ }
133
+ clearTimeout(pending.timeout);
134
+ this.pendingSelections.delete(selectionId);
135
+ if (!pending.settled) {
136
+ pending.settled = true;
137
+ pending.reject(new Error("Template selection cancelled"));
138
+ }
139
+ };
140
+ return {
141
+ galleryUrl,
142
+ callbackUrl,
143
+ setCompletionNotifier: (notifier) => {
144
+ const pending = this.pendingSelections.get(selectionId);
145
+ if (pending) {
146
+ pending.completionNotifier = notifier;
147
+ }
148
+ },
149
+ waitForSelection: async (signal) => {
150
+ const pending = this.pendingSelections.get(selectionId);
151
+ if (!pending) {
152
+ throw new Error("Template selection is no longer active");
153
+ }
154
+ const abortPromise = signal
155
+ ? new Promise((_, reject) => {
156
+ const onAbort = () => {
157
+ signal.removeEventListener("abort", onAbort);
158
+ cleanup();
159
+ reject(new Error("Template selection was aborted"));
160
+ };
161
+ signal.addEventListener("abort", onAbort, { once: true });
162
+ pending.promise.finally(() => signal.removeEventListener("abort", onAbort));
163
+ })
164
+ : null;
165
+ return abortPromise
166
+ ? Promise.race([pending.promise, abortPromise])
167
+ : pending.promise;
168
+ },
169
+ cleanup,
170
+ };
171
+ }
172
+ async close() {
173
+ for (const [selectionId, pending] of this.pendingSelections) {
174
+ clearTimeout(pending.timeout);
175
+ if (!pending.settled) {
176
+ pending.settled = true;
177
+ pending.reject(new Error("Template selection server closed"));
178
+ }
179
+ this.pendingSelections.delete(selectionId);
180
+ }
181
+ if (!this.listenPromise) {
182
+ return;
183
+ }
184
+ await new Promise((resolve, reject) => {
185
+ this.server.close((error) => {
186
+ if (error) {
187
+ reject(error);
188
+ return;
189
+ }
190
+ resolve();
191
+ });
192
+ });
193
+ }
194
+ async handleRequest(request, response) {
195
+ const url = new URL(request.url || "/", "http://127.0.0.1");
196
+ if (request.method !== "GET" || url.pathname !== "/template-selection/select") {
197
+ sendHtml(response, 404, errorHtml("Unknown template selection route."));
198
+ return;
199
+ }
200
+ const selectionId = url.searchParams.get("selectionId");
201
+ const templateId = url.searchParams.get("templateId");
202
+ if (!selectionId || !templateId) {
203
+ sendHtml(response, 400, errorHtml("Missing selection id or template id in callback URL."));
204
+ return;
205
+ }
206
+ const pending = this.pendingSelections.get(selectionId);
207
+ if (!pending) {
208
+ sendHtml(response, 410, errorHtml("This template selection has expired."));
209
+ return;
210
+ }
211
+ if (pending.allowedTemplateIds.size > 0 &&
212
+ !pending.allowedTemplateIds.has(templateId)) {
213
+ sendHtml(response, 400, errorHtml("That template is not part of the allowed selection set."));
214
+ return;
215
+ }
216
+ clearTimeout(pending.timeout);
217
+ this.pendingSelections.delete(selectionId);
218
+ if (!pending.settled) {
219
+ pending.settled = true;
220
+ pending.resolve(templateId);
221
+ }
222
+ try {
223
+ await pending.completionNotifier?.();
224
+ }
225
+ catch {
226
+ // The selection itself is already resolved; notification failure should not block the user.
227
+ }
228
+ sendHtml(response, 200, successHtml(templateId));
229
+ }
230
+ }
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ import { fail, ok } from "./utils.js";
3
+ import { lookupStoreMetadata } from "./store-metadata.js";
4
+ export function registerAnalysisTools(server, client) {
5
+ server.registerTool("analyze_app", {
6
+ title: "Analyze App",
7
+ description: "Analyze an App Store or Google Play URL and return app metadata",
8
+ inputSchema: {
9
+ app: z.string().min(1),
10
+ country: z.string().length(2).optional(),
11
+ },
12
+ }, async ({ app, country }) => {
13
+ try {
14
+ const data = await lookupStoreMetadata(client, app, country || "us");
15
+ return ok(data, data.provider === "google_play"
16
+ ? "Fetched Google Play metadata"
17
+ : "Fetched App Store metadata");
18
+ }
19
+ catch (error) {
20
+ return fail(error);
21
+ }
22
+ });
23
+ }
@@ -0,0 +1,84 @@
1
+ import { z } from "zod";
2
+ import { fail, ok } from "./utils.js";
3
+ export function registerAsoTools(server, client) {
4
+ server.registerTool("generate_aso_copy", {
5
+ title: "Generate ASO Copy",
6
+ description: "Generate ASO copy for a project",
7
+ inputSchema: {
8
+ generationId: z.string().uuid(),
9
+ variantId: z.string().uuid().optional(),
10
+ metadata: z.record(z.any()).optional(),
11
+ competitorAppIds: z.array(z.number()).optional(),
12
+ },
13
+ }, async (args) => {
14
+ try {
15
+ return ok(await client.generateAsoCopy(args), "Generated ASO copy");
16
+ }
17
+ catch (error) {
18
+ return fail(error);
19
+ }
20
+ });
21
+ server.registerTool("get_aso_copy", {
22
+ title: "Get ASO Copy",
23
+ description: "Get ASO copy for a project",
24
+ inputSchema: {
25
+ generationId: z.string().uuid(),
26
+ variantId: z.string().uuid().optional(),
27
+ },
28
+ }, async ({ generationId, variantId }) => {
29
+ try {
30
+ return ok(await client.getAsoCopy(generationId, variantId), "Fetched ASO copy");
31
+ }
32
+ catch (error) {
33
+ return fail(error);
34
+ }
35
+ });
36
+ server.registerTool("update_aso_copy", {
37
+ title: "Update ASO Copy",
38
+ description: "Persist edited ASO copy",
39
+ inputSchema: {
40
+ generationId: z.string().uuid(),
41
+ variantId: z.string().uuid().optional(),
42
+ asoCopy: z.record(z.any()),
43
+ },
44
+ }, async (args) => {
45
+ try {
46
+ return ok(await client.updateAsoCopy(args), "Updated ASO copy");
47
+ }
48
+ catch (error) {
49
+ return fail(error);
50
+ }
51
+ });
52
+ server.registerTool("translate_aso_copy", {
53
+ title: "Translate ASO Copy",
54
+ description: "Translate ASO copy into target languages",
55
+ inputSchema: {
56
+ generationId: z.string().uuid(),
57
+ variantId: z.string().uuid().optional(),
58
+ targetLanguages: z.array(z.string()).min(1),
59
+ },
60
+ }, async (args) => {
61
+ try {
62
+ return ok(await client.translateAsoCopy(args), "Translated ASO copy");
63
+ }
64
+ catch (error) {
65
+ return fail(error);
66
+ }
67
+ });
68
+ server.registerTool("suggest_competitors", {
69
+ title: "Suggest Competitors",
70
+ description: "Suggest competitor apps for ASO analysis",
71
+ inputSchema: {
72
+ appName: z.string(),
73
+ category: z.string(),
74
+ platform: z.enum(["ios", "android", "both"]).optional(),
75
+ },
76
+ }, async (args) => {
77
+ try {
78
+ return ok(await client.suggestCompetitors(args), "Fetched competitors");
79
+ }
80
+ catch (error) {
81
+ return fail(error);
82
+ }
83
+ });
84
+ }
@@ -0,0 +1,200 @@
1
+ import { promises as fs } from "fs";
2
+ import path from "path";
3
+ import { z } from "zod";
4
+ import { fail, ok } from "./utils.js";
5
+ const uploadSourceSchema = z
6
+ .object({
7
+ path: z.string().optional(),
8
+ url: z.string().url().optional(),
9
+ base64: z.string().optional(),
10
+ filename: z.string().optional(),
11
+ })
12
+ .refine((value) => !!value.path || !!value.url || !!value.base64, {
13
+ message: "Provide path, url, or base64",
14
+ });
15
+ function inferMimeType(filename) {
16
+ const extension = path.extname(filename).toLowerCase();
17
+ switch (extension) {
18
+ case ".png":
19
+ return "image/png";
20
+ case ".jpg":
21
+ case ".jpeg":
22
+ return "image/jpeg";
23
+ case ".webp":
24
+ return "image/webp";
25
+ case ".gif":
26
+ return "image/gif";
27
+ case ".svg":
28
+ return "image/svg+xml";
29
+ case ".ttf":
30
+ return "font/ttf";
31
+ case ".otf":
32
+ return "font/otf";
33
+ case ".woff":
34
+ return "font/woff";
35
+ case ".woff2":
36
+ return "font/woff2";
37
+ default:
38
+ return "application/octet-stream";
39
+ }
40
+ }
41
+ async function expandPathSource(sourcePath) {
42
+ const stat = await fs.stat(sourcePath);
43
+ if (!stat.isDirectory()) {
44
+ return [sourcePath];
45
+ }
46
+ const entries = await fs.readdir(sourcePath);
47
+ return entries
48
+ .filter((entry) => [".png", ".jpg", ".jpeg", ".webp", ".gif"].includes(path.extname(entry).toLowerCase()))
49
+ .map((entry) => path.join(sourcePath, entry))
50
+ .sort();
51
+ }
52
+ async function resolveUploadPayload(source) {
53
+ if (source.path) {
54
+ const expanded = await expandPathSource(source.path);
55
+ return Promise.all(expanded.map(async (filePath) => {
56
+ const buffer = await fs.readFile(filePath);
57
+ return {
58
+ buffer,
59
+ filename: source.filename || path.basename(filePath),
60
+ contentType: inferMimeType(source.filename || filePath),
61
+ };
62
+ }));
63
+ }
64
+ if (source.url) {
65
+ const response = await fetch(source.url);
66
+ if (!response.ok) {
67
+ throw new Error(`Failed to fetch ${source.url}: ${response.status}`);
68
+ }
69
+ const arrayBuffer = await response.arrayBuffer();
70
+ const filename = source.filename || source.url.split("/").pop() || "upload.png";
71
+ return [
72
+ {
73
+ buffer: Buffer.from(arrayBuffer),
74
+ filename,
75
+ contentType: response.headers.get("content-type") || inferMimeType(filename),
76
+ },
77
+ ];
78
+ }
79
+ const filename = source.filename || "upload.png";
80
+ const normalizedBase64 = source.base64.replace(/^data:[^;]+;base64,/, "");
81
+ return [
82
+ {
83
+ buffer: Buffer.from(normalizedBase64, "base64"),
84
+ filename,
85
+ contentType: inferMimeType(filename),
86
+ },
87
+ ];
88
+ }
89
+ export function registerAssetTools(server, client) {
90
+ server.registerTool("upload_screenshots", {
91
+ title: "Upload Screenshots",
92
+ description: "Upload screenshot images from local paths, URLs, or base64 for screenshot generation workflows.",
93
+ inputSchema: {
94
+ projectId: z.string().uuid(),
95
+ deviceType: z.enum(["mobile", "tablet", "desktop", "watch"]),
96
+ platform: z.enum(["ios", "android"]),
97
+ sources: z.array(uploadSourceSchema).min(1),
98
+ },
99
+ }, async ({ projectId, deviceType, platform, sources }) => {
100
+ try {
101
+ const uploads = [];
102
+ for (const source of sources) {
103
+ const payloads = await resolveUploadPayload(source);
104
+ for (const payload of payloads) {
105
+ const signed = await client.createSignedUpload({
106
+ projectId,
107
+ filename: payload.filename,
108
+ contentType: payload.contentType,
109
+ deviceType,
110
+ platform,
111
+ });
112
+ await client.uploadBinary(signed.uploadUrl, payload.buffer, payload.contentType);
113
+ uploads.push({
114
+ filename: signed.filename,
115
+ path: signed.path,
116
+ fullPath: signed.fullPath,
117
+ subfolder: signed.subfolder,
118
+ });
119
+ }
120
+ }
121
+ return ok({ uploads }, "Uploaded assets");
122
+ }
123
+ catch (error) {
124
+ return fail(error);
125
+ }
126
+ });
127
+ server.registerTool("list_illustrations", {
128
+ title: "List Illustrations",
129
+ description: "List available illustrations. Use 'shared' source to browse the shared library (icons, stickers, etc.). " +
130
+ "Use 'project' source to list illustrations uploaded to a specific project. " +
131
+ "When the user wants to add an illustration, list available options FIRST so they can pick one or choose to upload.",
132
+ inputSchema: {
133
+ source: z
134
+ .enum(["shared", "project"])
135
+ .describe("'shared' for the shared library, 'project' for project-uploaded illustrations."),
136
+ projectId: z
137
+ .string()
138
+ .uuid()
139
+ .optional()
140
+ .describe("Required when source is 'project'."),
141
+ category: z
142
+ .string()
143
+ .optional()
144
+ .describe("Filter shared library by category (e.g. 'Icons', 'Sticker', 'Illustrations')."),
145
+ search: z
146
+ .string()
147
+ .optional()
148
+ .describe("Search term to filter by name."),
149
+ },
150
+ }, async ({ source, projectId, category, search }) => {
151
+ try {
152
+ if (source === "project") {
153
+ if (!projectId) {
154
+ throw new Error("projectId is required when source is 'project'");
155
+ }
156
+ return ok(await client.listProjectIllustrations(projectId), "Fetched project illustrations");
157
+ }
158
+ return ok(await client.listSharedIllustrations({ category, search, limit: 50 }), "Fetched shared illustrations");
159
+ }
160
+ catch (error) {
161
+ return fail(error);
162
+ }
163
+ });
164
+ server.registerTool("upload_asset", {
165
+ title: "Upload Asset",
166
+ description: "Upload an image asset (panorama background, illustration, logo, or background image) from a local path. " +
167
+ "Returns the stored path which can then be used in transform_layout operations " +
168
+ "(e.g. set panoramaBackground.imageUrl or illustration imageUrl to the returned path). " +
169
+ "For illustrations: list_illustrations first to show existing options, then upload only if the user wants a custom image. " +
170
+ "For panoramas: ask the user to provide a local image path to upload.",
171
+ inputSchema: {
172
+ projectId: z.string().uuid(),
173
+ fileType: z
174
+ .enum(["illustration", "logo", "panorama", "background"])
175
+ .describe("Type of asset: 'panorama' for panorama backgrounds, 'illustration' for decorative images/stickers, 'logo' for app logo, 'background' for per-screen background images."),
176
+ source: uploadSourceSchema.describe("The image source — provide a local file path, a URL, or base64 data."),
177
+ },
178
+ }, async ({ projectId, fileType, source }) => {
179
+ try {
180
+ const payloads = await resolveUploadPayload(source);
181
+ const payload = payloads[0];
182
+ const signed = await client.createSignedUpload({
183
+ projectId,
184
+ filename: payload.filename,
185
+ contentType: payload.contentType,
186
+ fileType,
187
+ });
188
+ await client.uploadBinary(signed.uploadUrl, payload.buffer, payload.contentType);
189
+ return ok({
190
+ filename: signed.filename,
191
+ path: signed.path,
192
+ fullPath: signed.fullPath,
193
+ subfolder: signed.subfolder,
194
+ }, `Uploaded ${fileType} asset`);
195
+ }
196
+ catch (error) {
197
+ return fail(error);
198
+ }
199
+ });
200
+ }
@@ -0,0 +1,69 @@
1
+ import { z } from "zod";
2
+ import { fail, ok } from "./utils.js";
3
+ export function registerGraphicsTools(server, client) {
4
+ server.registerTool("generate_graphics", {
5
+ title: "Generate Graphics",
6
+ description: "Generate social graphics suggestions for a project",
7
+ inputSchema: {
8
+ generationId: z.string().uuid(),
9
+ templateId: z.string(),
10
+ },
11
+ }, async (args) => {
12
+ try {
13
+ return ok(await client.generateGraphics(args), "Generated graphics");
14
+ }
15
+ catch (error) {
16
+ return fail(error);
17
+ }
18
+ });
19
+ server.registerTool("get_graphics", {
20
+ title: "Get Graphics",
21
+ description: "Get saved social graphics for a project",
22
+ inputSchema: {
23
+ projectId: z.string().uuid(),
24
+ variantId: z.string().uuid().optional(),
25
+ },
26
+ }, async ({ projectId, variantId }) => {
27
+ try {
28
+ return ok(await client.getGraphics(projectId, variantId), "Fetched graphics");
29
+ }
30
+ catch (error) {
31
+ return fail(error);
32
+ }
33
+ });
34
+ server.registerTool("save_graphics", {
35
+ title: "Save Graphics",
36
+ description: "Persist social graphics layouts for a project",
37
+ inputSchema: {
38
+ generationId: z.string().uuid(),
39
+ variantId: z.string().uuid().optional(),
40
+ socialTemplateId: z.string(),
41
+ socialPrimaryFormat: z.enum([
42
+ "og",
43
+ "x_post",
44
+ "play_store_feature",
45
+ "x_header",
46
+ "linkedin_banner",
47
+ ]),
48
+ graphics: z
49
+ .array(z.object({
50
+ format: z.enum([
51
+ "og",
52
+ "x_post",
53
+ "play_store_feature",
54
+ "x_header",
55
+ "linkedin_banner",
56
+ ]),
57
+ layout: z.record(z.any()),
58
+ }))
59
+ .min(1),
60
+ },
61
+ }, async (args) => {
62
+ try {
63
+ return ok(await client.saveGraphics(args), "Saved graphics");
64
+ }
65
+ catch (error) {
66
+ return fail(error);
67
+ }
68
+ });
69
+ }