mcp-stitch 0.1.2 → 0.1.4

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.
@@ -1,5 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { lstat, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdir, readdir, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
5
  import { getStitchConfig } from "../config/stitch.js";
@@ -23,6 +23,9 @@ const ARTIFACT_FILES = [
23
23
  "manifest.json",
24
24
  ];
25
25
  const DEFAULT_ARTIFACT_ROOT = ".artifacts/stitch";
26
+ const DEFAULT_MAX_LINKED_ASSETS = 50;
27
+ const DEFAULT_MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
28
+ const VERSION_DIR_PATTERN = /^v(\d{3})$/;
26
29
  function isSafeRelativePathInput(value) {
27
30
  const trimmed = value.trim();
28
31
  if (!trimmed)
@@ -46,6 +49,206 @@ function nowStamp() {
46
49
  function toPrettyJson(value) {
47
50
  return JSON.stringify(value, null, 2);
48
51
  }
52
+ function safeUrl(url) {
53
+ try {
54
+ const parsed = new URL(url);
55
+ if (parsed.protocol !== "https:")
56
+ return null;
57
+ const hostname = parsed.hostname.toLowerCase();
58
+ if (hostname === "localhost" ||
59
+ hostname.endsWith(".localhost") ||
60
+ hostname.endsWith(".local") ||
61
+ hostname === "0.0.0.0" ||
62
+ hostname === "127.0.0.1" ||
63
+ hostname === "::1" ||
64
+ hostname.startsWith("10.") ||
65
+ hostname.startsWith("192.168.") ||
66
+ /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)) {
67
+ return null;
68
+ }
69
+ return parsed;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ function extensionFromContentType(contentType) {
76
+ const normalized = contentType?.split(";")[0]?.trim().toLowerCase();
77
+ switch (normalized) {
78
+ case "text/html":
79
+ return ".html";
80
+ case "image/jpeg":
81
+ return ".jpg";
82
+ case "image/png":
83
+ return ".png";
84
+ case "image/webp":
85
+ return ".webp";
86
+ case "image/gif":
87
+ return ".gif";
88
+ case "image/svg+xml":
89
+ return ".svg";
90
+ case "image/avif":
91
+ return ".avif";
92
+ case "font/woff":
93
+ return ".woff";
94
+ case "font/woff2":
95
+ return ".woff2";
96
+ case "text/css":
97
+ return ".css";
98
+ case "application/javascript":
99
+ case "text/javascript":
100
+ return ".js";
101
+ default:
102
+ return ".bin";
103
+ }
104
+ }
105
+ function extensionFromUrl(url) {
106
+ const extension = path.extname(url.pathname).toLowerCase();
107
+ return /^[a-z0-9.]{2,12}$/.test(extension) ? extension : "";
108
+ }
109
+ function fileNameWithExtension(stem, url, contentType) {
110
+ const extension = extensionFromUrl(url) || extensionFromContentType(contentType);
111
+ return `${stem}${extension}`;
112
+ }
113
+ function assetFileNameStem(prefix, index) {
114
+ return `${prefix}-${String(index).padStart(3, "0")}`;
115
+ }
116
+ function dedupeUrls(urls) {
117
+ const seen = new Set();
118
+ const result = [];
119
+ for (const url of urls) {
120
+ if (seen.has(url))
121
+ continue;
122
+ seen.add(url);
123
+ result.push(url);
124
+ }
125
+ return result;
126
+ }
127
+ function extractLinkedAssetUrls(html, baseUrl) {
128
+ const candidates = [];
129
+ const attrPattern = /\b(?:src|href)=["']([^"']+)["']/gi;
130
+ const cssUrlPattern = /url\(["']?([^"')]+)["']?\)/gi;
131
+ for (const match of html.matchAll(attrPattern)) {
132
+ if (match[1])
133
+ candidates.push(match[1]);
134
+ }
135
+ for (const match of html.matchAll(cssUrlPattern)) {
136
+ if (match[1])
137
+ candidates.push(match[1]);
138
+ }
139
+ const urls = candidates
140
+ .map((candidate) => {
141
+ try {
142
+ return new URL(candidate, baseUrl).toString();
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ })
148
+ .filter((url) => Boolean(url))
149
+ .filter((url) => safeUrl(url) !== null);
150
+ return dedupeUrls(urls);
151
+ }
152
+ function isLikelyLinkedAsset(contentType) {
153
+ const normalized = contentType?.split(";")[0]?.trim().toLowerCase();
154
+ return Boolean(normalized &&
155
+ (normalized.startsWith("image/") ||
156
+ normalized.startsWith("font/") ||
157
+ normalized === "text/css" ||
158
+ normalized === "application/javascript" ||
159
+ normalized === "text/javascript"));
160
+ }
161
+ async function downloadUrl(options) {
162
+ const parsed = safeUrl(options.url);
163
+ if (!parsed) {
164
+ return {
165
+ asset: {
166
+ kind: options.kind,
167
+ sourceUrl: options.url,
168
+ status: "skipped",
169
+ reason: "Only safe https URLs are downloaded.",
170
+ },
171
+ };
172
+ }
173
+ try {
174
+ const response = await fetch(parsed);
175
+ const contentType = response.headers.get("content-type") ?? undefined;
176
+ const contentLength = response.headers.get("content-length");
177
+ if (!response.ok) {
178
+ return {
179
+ asset: {
180
+ kind: options.kind,
181
+ sourceUrl: options.url,
182
+ contentType,
183
+ status: "failed",
184
+ reason: `HTTP ${response.status}`,
185
+ },
186
+ };
187
+ }
188
+ if (options.requireLinkedAssetType && !isLikelyLinkedAsset(contentType)) {
189
+ return {
190
+ asset: {
191
+ kind: options.kind,
192
+ sourceUrl: options.url,
193
+ contentType,
194
+ status: "skipped",
195
+ reason: "Response content type is not a linked asset type.",
196
+ },
197
+ };
198
+ }
199
+ if (contentLength && Number(contentLength) > options.maxBytes) {
200
+ return {
201
+ asset: {
202
+ kind: options.kind,
203
+ sourceUrl: options.url,
204
+ contentType,
205
+ status: "skipped",
206
+ reason: `Content length exceeds ${options.maxBytes} bytes.`,
207
+ },
208
+ };
209
+ }
210
+ const arrayBuffer = await response.arrayBuffer();
211
+ if (arrayBuffer.byteLength > options.maxBytes) {
212
+ return {
213
+ asset: {
214
+ kind: options.kind,
215
+ sourceUrl: options.url,
216
+ contentType,
217
+ bytes: arrayBuffer.byteLength,
218
+ status: "skipped",
219
+ reason: `Downloaded content exceeds ${options.maxBytes} bytes.`,
220
+ },
221
+ };
222
+ }
223
+ const buffer = Buffer.from(arrayBuffer);
224
+ const fileName = options.forcedFileName ?? fileNameWithExtension(options.fileNameStem, parsed, contentType);
225
+ const relativePath = path.join(options.relativeDir, fileName);
226
+ const outputPath = await prepareSafeOutputPath(options.baseRoot, relativePath);
227
+ await writeFile(outputPath, buffer);
228
+ return {
229
+ asset: {
230
+ kind: options.kind,
231
+ sourceUrl: options.url,
232
+ path: outputPath,
233
+ relativePath,
234
+ contentType,
235
+ bytes: buffer.byteLength,
236
+ status: "saved",
237
+ },
238
+ text: contentType?.startsWith("text/") ? buffer.toString("utf8") : undefined,
239
+ };
240
+ }
241
+ catch (error) {
242
+ return {
243
+ asset: {
244
+ kind: options.kind,
245
+ sourceUrl: options.url,
246
+ status: "failed",
247
+ reason: error instanceof Error ? error.message : String(error),
248
+ },
249
+ };
250
+ }
251
+ }
49
252
  function defaultArtifactName(screenId) {
50
253
  const suffix = screenId ? sanitizeFileName(screenId) : "screen";
51
254
  return `stitch-${suffix}-${nowStamp()}`;
@@ -82,6 +285,26 @@ async function getBaseRoot(configOutputDir) {
82
285
  }
83
286
  return { ok: true, baseRoot: path.resolve(configOutputDir), source: "STITCH_OUTPUT_DIR" };
84
287
  }
288
+ async function nextVersionedArtifactPath(options) {
289
+ const basePath = await prepareSafeOutputPath(options.baseRoot, path.join(options.artifactPath, ".version-probe"));
290
+ const versionBaseDir = path.dirname(basePath);
291
+ await mkdir(versionBaseDir, { recursive: true });
292
+ let maxVersion = 0;
293
+ const entries = await readdir(versionBaseDir, { withFileTypes: true });
294
+ for (const entry of entries) {
295
+ if (!entry.isDirectory())
296
+ continue;
297
+ const match = entry.name.match(VERSION_DIR_PATTERN);
298
+ if (!match?.[1])
299
+ continue;
300
+ maxVersion = Math.max(maxVersion, Number(match[1]));
301
+ }
302
+ const version = `v${String(maxVersion + 1).padStart(3, "0")}`;
303
+ return {
304
+ artifactPath: path.join(options.artifactPath, version),
305
+ version,
306
+ };
307
+ }
85
308
  function defaultArtifactPath(payload, screenId) {
86
309
  const screen = getScreen(payload);
87
310
  const title = getString(screen, "title");
@@ -672,6 +895,14 @@ function createManifest(options) {
672
895
  mode: options.outputMode,
673
896
  },
674
897
  paths: options.paths,
898
+ downloadedAssets: options.downloadedAssets,
899
+ version: options.versionInfo.enabled
900
+ ? {
901
+ enabled: true,
902
+ baseArtifactPath: options.versionInfo.baseArtifactPath,
903
+ version: options.versionInfo.version,
904
+ }
905
+ : { enabled: false },
675
906
  };
676
907
  }
677
908
  export function registerStitchExportTool(server) {
@@ -708,8 +939,47 @@ export function registerStitchExportTool(server) {
708
939
  .describe("Legacy workspace-relative artifact bundle directory, for example exports/my-screen."),
709
940
  rawGetScreenInput: z.record(z.string(), z.unknown()).optional(),
710
941
  screenData: z.unknown().optional(),
942
+ versioned: z
943
+ .boolean()
944
+ .optional()
945
+ .default(false)
946
+ .describe("When true, writes into the next nested version folder such as artifactPath/v001, then v002."),
947
+ includeHtml: z
948
+ .boolean()
949
+ .optional()
950
+ .default(true)
951
+ .describe("Download the screen htmlCode.downloadUrl into screen.html when present."),
952
+ includeScreenshot: z
953
+ .boolean()
954
+ .optional()
955
+ .default(true)
956
+ .describe("Download the screen screenshot.downloadUrl into screenshot.* when present."),
957
+ includeLinkedAssets: z
958
+ .boolean()
959
+ .optional()
960
+ .default(false)
961
+ .describe("Download safe HTTPS assets referenced by the exported HTML into assets/."),
962
+ rewriteHtmlAssetUrls: z
963
+ .boolean()
964
+ .optional()
965
+ .default(false)
966
+ .describe("Rewrite saved screen.html to point at downloaded local asset files."),
967
+ maxLinkedAssets: z
968
+ .number()
969
+ .int()
970
+ .min(1)
971
+ .max(200)
972
+ .optional()
973
+ .default(DEFAULT_MAX_LINKED_ASSETS),
974
+ maxDownloadBytes: z
975
+ .number()
976
+ .int()
977
+ .min(1024)
978
+ .max(100 * 1024 * 1024)
979
+ .optional()
980
+ .default(DEFAULT_MAX_DOWNLOAD_BYTES),
711
981
  },
712
- }, async ({ screenId, projectId, artifactPath, artifactName, relativePath, rawGetScreenInput, screenData }) => {
982
+ }, async ({ screenId, projectId, artifactPath, artifactName, relativePath, rawGetScreenInput, screenData, versioned = false, includeHtml = true, includeScreenshot = true, includeLinkedAssets = false, rewriteHtmlAssetUrls = false, maxLinkedAssets = DEFAULT_MAX_LINKED_ASSETS, maxDownloadBytes = DEFAULT_MAX_DOWNLOAD_BYTES, }) => {
713
983
  if (!screenData && !screenId && !rawGetScreenInput) {
714
984
  return {
715
985
  content: [
@@ -807,11 +1077,38 @@ export function registerStitchExportTool(server) {
807
1077
  };
808
1078
  }
809
1079
  const outputMode = artifactPath || relativePath ? "artifactPath" : artifactName ? "artifactName" : "default";
810
- const relativeDir = artifactPath ??
1080
+ const baseRelativeDir = artifactPath ??
811
1081
  relativePath ??
812
1082
  (artifactName
813
1083
  ? path.join("exports", sanitizeFileName(artifactName))
814
1084
  : defaultArtifactPath(payload, screenId));
1085
+ let relativeDir = baseRelativeDir;
1086
+ let versionInfo = { enabled: false };
1087
+ if (versioned) {
1088
+ try {
1089
+ const versionedPath = await nextVersionedArtifactPath({
1090
+ baseRoot: baseRootInfo.baseRoot,
1091
+ artifactPath: baseRelativeDir,
1092
+ });
1093
+ relativeDir = versionedPath.artifactPath;
1094
+ versionInfo = {
1095
+ enabled: true,
1096
+ baseArtifactPath: baseRelativeDir,
1097
+ version: versionedPath.version,
1098
+ };
1099
+ }
1100
+ catch (error) {
1101
+ const message = error instanceof Error ? error.message : String(error);
1102
+ return {
1103
+ content: [
1104
+ {
1105
+ type: "text",
1106
+ text: `Invalid versioned output path.\n\n${message}`,
1107
+ },
1108
+ ],
1109
+ };
1110
+ }
1111
+ }
815
1112
  const outputPaths = {
816
1113
  "raw.json": "",
817
1114
  "screen-summary.md": "",
@@ -855,6 +1152,107 @@ export function registerStitchExportTool(server) {
855
1152
  const acceptanceCriteria = buildAcceptanceCriteria(payload);
856
1153
  const testPlan = buildTestPlan(payload);
857
1154
  const questions = buildQuestions(payload);
1155
+ const allOutputPaths = { ...outputPaths };
1156
+ const downloadedAssets = [];
1157
+ const screen = getScreen(payload);
1158
+ const htmlCode = getNestedRecord(screen, "htmlCode");
1159
+ const screenshot = getNestedRecord(screen, "screenshot");
1160
+ const htmlUrl = getString(htmlCode, "downloadUrl");
1161
+ const screenshotUrl = getString(screenshot, "downloadUrl");
1162
+ let savedHtmlPath;
1163
+ let savedHtmlText;
1164
+ if (includeHtml && htmlUrl) {
1165
+ const htmlDownload = await downloadUrl({
1166
+ url: htmlUrl,
1167
+ baseRoot: baseRootInfo.baseRoot,
1168
+ relativeDir,
1169
+ fileNameStem: "screen",
1170
+ forcedFileName: "screen.html",
1171
+ kind: "html",
1172
+ maxBytes: maxDownloadBytes,
1173
+ });
1174
+ downloadedAssets.push(htmlDownload.asset);
1175
+ if (htmlDownload.asset.status === "saved") {
1176
+ savedHtmlPath = htmlDownload.asset.path;
1177
+ savedHtmlText = htmlDownload.text;
1178
+ if (htmlDownload.asset.relativePath) {
1179
+ allOutputPaths["screen.html"] = htmlDownload.asset.path ?? "";
1180
+ }
1181
+ }
1182
+ }
1183
+ if (includeScreenshot && screenshotUrl) {
1184
+ const screenshotDownload = await downloadUrl({
1185
+ url: screenshotUrl,
1186
+ baseRoot: baseRootInfo.baseRoot,
1187
+ relativeDir,
1188
+ fileNameStem: "screenshot",
1189
+ kind: "screenshot",
1190
+ maxBytes: maxDownloadBytes,
1191
+ });
1192
+ downloadedAssets.push(screenshotDownload.asset);
1193
+ if (screenshotDownload.asset.status === "saved" && screenshotDownload.asset.path) {
1194
+ allOutputPaths[path.basename(screenshotDownload.asset.path)] = screenshotDownload.asset.path;
1195
+ }
1196
+ }
1197
+ if (includeLinkedAssets && htmlUrl) {
1198
+ if (!savedHtmlText) {
1199
+ const htmlDownload = await downloadUrl({
1200
+ url: htmlUrl,
1201
+ baseRoot: baseRootInfo.baseRoot,
1202
+ relativeDir,
1203
+ fileNameStem: "screen",
1204
+ forcedFileName: "screen.html",
1205
+ kind: "html",
1206
+ maxBytes: maxDownloadBytes,
1207
+ });
1208
+ downloadedAssets.push(htmlDownload.asset);
1209
+ if (htmlDownload.asset.status === "saved") {
1210
+ savedHtmlPath = htmlDownload.asset.path;
1211
+ savedHtmlText = htmlDownload.text;
1212
+ if (htmlDownload.asset.path) {
1213
+ allOutputPaths["screen.html"] = htmlDownload.asset.path;
1214
+ }
1215
+ }
1216
+ }
1217
+ const linkedUrls = savedHtmlText
1218
+ ? extractLinkedAssetUrls(savedHtmlText, htmlUrl).slice(0, maxLinkedAssets)
1219
+ : [];
1220
+ const rewriteMap = new Map();
1221
+ for (const [index, linkedUrl] of linkedUrls.entries()) {
1222
+ const linkedAsset = await downloadUrl({
1223
+ url: linkedUrl,
1224
+ baseRoot: baseRootInfo.baseRoot,
1225
+ relativeDir: path.join(relativeDir, "assets"),
1226
+ fileNameStem: assetFileNameStem("asset", index + 1),
1227
+ kind: "linked-asset",
1228
+ maxBytes: maxDownloadBytes,
1229
+ requireLinkedAssetType: true,
1230
+ });
1231
+ downloadedAssets.push(linkedAsset.asset);
1232
+ if (linkedAsset.asset.status === "saved" && linkedAsset.asset.path) {
1233
+ allOutputPaths[`assets/${path.basename(linkedAsset.asset.path)}`] = linkedAsset.asset.path;
1234
+ if (savedHtmlPath) {
1235
+ const localReference = path
1236
+ .relative(path.dirname(savedHtmlPath), linkedAsset.asset.path)
1237
+ .split(path.sep)
1238
+ .join("/");
1239
+ rewriteMap.set(linkedUrl, localReference.startsWith(".") ? localReference : `./${localReference}`);
1240
+ }
1241
+ }
1242
+ }
1243
+ if (rewriteHtmlAssetUrls && savedHtmlPath && savedHtmlText && rewriteMap.size > 0) {
1244
+ let rewrittenHtml = savedHtmlText;
1245
+ for (const [remoteUrl, localReference] of rewriteMap) {
1246
+ rewrittenHtml = rewrittenHtml.split(remoteUrl).join(localReference);
1247
+ }
1248
+ await writeFile(savedHtmlPath, rewrittenHtml, "utf8");
1249
+ }
1250
+ }
1251
+ if (downloadedAssets.length > 0) {
1252
+ const assetManifestPath = await prepareSafeOutputPath(baseRootInfo.baseRoot, path.join(relativeDir, "asset-manifest.json"));
1253
+ allOutputPaths["asset-manifest.json"] = assetManifestPath;
1254
+ await writeFile(assetManifestPath, `${toPrettyJson({ generatedAt, assets: downloadedAssets })}\n`, "utf8");
1255
+ }
858
1256
  const manifest = createManifest({
859
1257
  payload,
860
1258
  input: {
@@ -866,10 +1264,19 @@ export function registerStitchExportTool(server) {
866
1264
  ...(rawGetScreenInput ? { rawGetScreenInput } : {}),
867
1265
  ...(screenData ? { screenDataProvided: true } : {}),
868
1266
  ...(fetchInput ? { fetchInput } : {}),
1267
+ versioned,
1268
+ includeHtml,
1269
+ includeScreenshot,
1270
+ includeLinkedAssets,
1271
+ rewriteHtmlAssetUrls,
1272
+ maxLinkedAssets,
1273
+ maxDownloadBytes,
869
1274
  },
870
1275
  resolver: resolverInfo,
871
1276
  generatedAt,
872
- paths: outputPaths,
1277
+ paths: allOutputPaths,
1278
+ downloadedAssets,
1279
+ versionInfo,
873
1280
  artifactPath: relativeDir,
874
1281
  resolvedOutputDir: path.dirname(outputPaths["manifest.json"]),
875
1282
  baseRoot: baseRootInfo.baseRoot,
@@ -888,9 +1295,11 @@ export function registerStitchExportTool(server) {
888
1295
  await writeFile(outputPaths["test-plan.md"], `${testPlan}\n`, "utf8");
889
1296
  await writeFile(outputPaths["questions.md"], `${questions}\n`, "utf8");
890
1297
  await writeFile(outputPaths["manifest.json"], `${toPrettyJson(manifest)}\n`, "utf8");
891
- const screen = getScreen(payload);
892
1298
  const title = getString(screen, "title") ?? "(untitled)";
893
1299
  const screenName = getString(screen, "name") ?? "(unknown)";
1300
+ const savedCount = downloadedAssets.filter((asset) => asset.status === "saved").length;
1301
+ const skippedCount = downloadedAssets.filter((asset) => asset.status === "skipped").length;
1302
+ const failedCount = downloadedAssets.filter((asset) => asset.status === "failed").length;
894
1303
  return {
895
1304
  content: [
896
1305
  {
@@ -899,10 +1308,14 @@ export function registerStitchExportTool(server) {
899
1308
  `Title: ${title}\n` +
900
1309
  `Screen: ${screenName}\n` +
901
1310
  `Artifact path: ${relativeDir}\n` +
1311
+ (versionInfo.enabled
1312
+ ? `Version: ${versionInfo.version} (${versionInfo.baseArtifactPath})\n`
1313
+ : "") +
902
1314
  `Base root: ${baseRootInfo.baseRoot} (${baseRootInfo.source})\n` +
903
1315
  `Output directory: ${path.dirname(outputPaths["manifest.json"])}\n\n` +
1316
+ `Downloaded assets: ${savedCount} saved, ${skippedCount} skipped, ${failedCount} failed\n\n` +
904
1317
  "Files:\n" +
905
- ARTIFACT_FILES.map((fileName) => `- ${fileName}: ${outputPaths[fileName]}`).join("\n"),
1318
+ Object.entries(allOutputPaths).map(([fileName, filePath]) => `- ${fileName}: ${filePath}`).join("\n"),
906
1319
  },
907
1320
  ],
908
1321
  };