sonance-brand-mcp 1.3.33 → 1.3.36

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.
@@ -77,6 +77,151 @@ function debugLog(message: string, data?: unknown) {
77
77
  }
78
78
  }
79
79
 
80
+ /**
81
+ * Phase 1: Extract visible text from screenshot
82
+ * The LLM reads the screenshot and extracts text content related to the user's edit request
83
+ */
84
+ async function extractTextFromScreenshot(
85
+ screenshot: string,
86
+ userPrompt: string,
87
+ apiKey: string
88
+ ): Promise<string[]> {
89
+ const anthropic = new Anthropic({ apiKey });
90
+
91
+ const base64Data = screenshot.split(",")[1] || screenshot;
92
+
93
+ const response = await anthropic.messages.create({
94
+ model: "claude-sonnet-4-20250514",
95
+ max_tokens: 1024,
96
+ messages: [
97
+ {
98
+ role: "user",
99
+ content: [
100
+ {
101
+ type: "image",
102
+ source: {
103
+ type: "base64",
104
+ media_type: "image/png",
105
+ data: base64Data,
106
+ },
107
+ },
108
+ {
109
+ type: "text",
110
+ text: `Look at this screenshot. The user wants to: "${userPrompt}"
111
+
112
+ Extract the EXACT text content visible in the UI elements that need to be edited.
113
+ Focus on button labels, headings, and any text within the area the user wants to change.
114
+
115
+ Return ONLY valid JSON:
116
+ { "textStrings": ["View Assets", "View Flowchart", "exact text 1", "exact text 2"] }
117
+
118
+ Be precise - return the exact text as it appears, no extra words.`,
119
+ },
120
+ ],
121
+ },
122
+ ],
123
+ });
124
+
125
+ try {
126
+ const textBlock = response.content.find((block) => block.type === "text");
127
+ if (!textBlock || textBlock.type !== "text") return [];
128
+
129
+ let jsonText = textBlock.text.trim();
130
+ const jsonMatch = jsonText.match(/```json\n([\s\S]*?)\n```/) ||
131
+ jsonText.match(/\{[\s\S]*\}/);
132
+ if (jsonMatch) {
133
+ jsonText = jsonMatch[1] || jsonMatch[0];
134
+ }
135
+
136
+ const parsed = JSON.parse(jsonText);
137
+ const strings = parsed.textStrings || [];
138
+
139
+ debugLog("Phase 1: Extracted text from screenshot", { textStrings: strings });
140
+ return strings;
141
+ } catch (e) {
142
+ debugLog("Phase 1: Failed to parse text extraction response", { error: String(e) });
143
+ return [];
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Search project files for exact text matches
149
+ * Returns file paths and content for files containing any of the search strings
150
+ */
151
+ function searchFilesForExactText(
152
+ searchStrings: string[],
153
+ projectRoot: string,
154
+ maxFiles: number = 10
155
+ ): { path: string; content: string }[] {
156
+ if (searchStrings.length === 0) return [];
157
+
158
+ const results: { path: string; content: string; matchCount: number }[] = [];
159
+ const searchDirs = ["components", "src/components", "app", "src/app"];
160
+ const extensions = [".tsx", ".jsx"];
161
+ const visited = new Set<string>();
162
+
163
+ function searchDir(dirPath: string, depth: number = 0) {
164
+ if (depth > 5 || results.length >= maxFiles) return;
165
+
166
+ const fullDirPath = path.join(projectRoot, dirPath);
167
+ if (!fs.existsSync(fullDirPath) || visited.has(fullDirPath)) return;
168
+ visited.add(fullDirPath);
169
+
170
+ try {
171
+ const entries = fs.readdirSync(fullDirPath, { withFileTypes: true });
172
+
173
+ for (const entry of entries) {
174
+ if (results.length >= maxFiles) break;
175
+
176
+ const entryPath = path.join(dirPath, entry.name);
177
+ const fullEntryPath = path.join(projectRoot, entryPath);
178
+
179
+ if (entry.isDirectory()) {
180
+ if (!entry.name.startsWith(".") && entry.name !== "node_modules") {
181
+ searchDir(entryPath, depth + 1);
182
+ }
183
+ } else if (entry.isFile() && extensions.some(ext => entry.name.endsWith(ext))) {
184
+ try {
185
+ const content = fs.readFileSync(fullEntryPath, "utf-8");
186
+
187
+ // Count exact text matches
188
+ let matchCount = 0;
189
+ for (const searchText of searchStrings) {
190
+ if (content.includes(searchText)) {
191
+ matchCount++;
192
+ }
193
+ }
194
+
195
+ if (matchCount > 0) {
196
+ results.push({ path: entryPath, content, matchCount });
197
+ }
198
+ } catch {
199
+ // Skip files that can't be read
200
+ }
201
+ }
202
+ }
203
+ } catch {
204
+ // Skip directories that can't be read
205
+ }
206
+ }
207
+
208
+ for (const dir of searchDirs) {
209
+ searchDir(dir);
210
+ if (results.length >= maxFiles) break;
211
+ }
212
+
213
+ // Sort by match count (most matches first)
214
+ results.sort((a, b) => b.matchCount - a.matchCount);
215
+
216
+ debugLog("Phase 2: Found files with text matches", {
217
+ searchStrings,
218
+ filesFound: results.length,
219
+ topMatches: results.slice(0, 5).map(r => ({ path: r.path, matchCount: r.matchCount }))
220
+ });
221
+
222
+ return results.slice(0, maxFiles).map(r => ({ path: r.path, content: r.content }));
223
+ }
224
+
80
225
  const VISION_SYSTEM_PROMPT = `You are an expert React/TypeScript developer with vision capabilities. You can see screenshots and modify code.
81
226
 
82
227
  ═══════════════════════════════════════════════════════════════════════════════
@@ -222,9 +367,40 @@ export async function POST(request: Request) {
222
367
  );
223
368
  }
224
369
 
370
+ // PHASE 1: Extract visible text from screenshot using LLM vision
371
+ // This helps find the correct component file even if it's not in the import chain
372
+ let textSearchFiles: { path: string; content: string }[] = [];
373
+ if (screenshot) {
374
+ debugLog("Starting Phase 1: Text extraction from screenshot");
375
+ const extractedText = await extractTextFromScreenshot(screenshot, userPrompt, apiKey);
376
+
377
+ if (extractedText.length > 0) {
378
+ // PHASE 2: Search project for files containing the extracted text
379
+ textSearchFiles = searchFilesForExactText(extractedText, projectRoot, 5);
380
+ debugLog("Phase 1+2 complete", {
381
+ extractedText,
382
+ filesFound: textSearchFiles.map(f => f.path)
383
+ });
384
+ }
385
+ }
386
+
225
387
  // Gather page context (including focused element files and keyword search)
226
388
  const pageContext = gatherPageContext(pageRoute, projectRoot, focusedElements, userPrompt);
227
389
 
390
+ // Add text-search discovered files to component sources (if not already present)
391
+ const existingPaths = new Set([
392
+ pageContext.pageFile,
393
+ ...pageContext.componentSources.map(c => c.path)
394
+ ]);
395
+
396
+ for (const file of textSearchFiles) {
397
+ if (!existingPaths.has(file.path)) {
398
+ // Insert at the beginning so these get priority
399
+ pageContext.componentSources.unshift(file);
400
+ debugLog("Added file from text search to context", { path: file.path });
401
+ }
402
+ }
403
+
228
404
  // Build user message with vision
229
405
  const messageContent: Anthropic.MessageCreateParams["messages"][0]["content"] = [];
230
406
 
@@ -393,39 +569,58 @@ CRITICAL: Only use file paths from the VALID FILES list above. Do NOT create new
393
569
  );
394
570
  }
395
571
 
396
- // Build list of valid file paths
397
- const validPaths = new Set<string>();
572
+ // Build list of known file paths (for logging)
573
+ const knownPaths = new Set<string>();
398
574
  if (pageContext.pageFile) {
399
- validPaths.add(pageContext.pageFile);
575
+ knownPaths.add(pageContext.pageFile);
400
576
  }
401
577
  for (const comp of pageContext.componentSources) {
402
- validPaths.add(comp.path);
578
+ knownPaths.add(comp.path);
403
579
  }
404
580
 
405
- debugLog("VALIDATION: Valid file paths from page context", {
581
+ debugLog("VALIDATION: Known file paths from page context", {
406
582
  pageFile: pageContext.pageFile,
407
- validPaths: Array.from(validPaths),
583
+ knownPaths: Array.from(knownPaths),
408
584
  aiRequestedFiles: (aiResponse.modifications || []).map(m => m.filePath)
409
585
  });
410
586
 
411
- // Validate AI response - reject any file paths not in our valid list
412
- const invalidMods = (aiResponse.modifications || []).filter(
413
- (mod) => !validPaths.has(mod.filePath)
414
- );
587
+ // Validate AI response - trust the LLM to identify the correct file
588
+ // Only reject paths that are outside the project or don't exist
589
+ for (const mod of aiResponse.modifications || []) {
590
+ const fullPath = path.join(projectRoot, mod.filePath);
415
591
 
416
- if (invalidMods.length > 0) {
417
- debugLog("REJECTED: AI attempted to create new files", { invalidMods: invalidMods.map(m => m.filePath) });
418
- console.error(
419
- "AI attempted to create new files:",
420
- invalidMods.map((m) => m.filePath)
421
- );
422
- return NextResponse.json(
423
- {
424
- success: false,
425
- error: `Cannot create new files. The following paths were not found in the project: ${invalidMods.map((m) => m.filePath).join(", ")}. Please try a more specific request targeting existing components.`,
426
- } as VisionEditResponse,
427
- { status: 400 }
428
- );
592
+ // Security: Ensure path is within project (prevent path traversal)
593
+ const normalizedPath = path.normalize(fullPath);
594
+ if (!normalizedPath.startsWith(projectRoot)) {
595
+ debugLog("REJECTED: Path outside project", { filePath: mod.filePath });
596
+ return NextResponse.json(
597
+ {
598
+ success: false,
599
+ error: `Invalid file path: ${mod.filePath} (outside project directory)`,
600
+ } as VisionEditResponse,
601
+ { status: 400 }
602
+ );
603
+ }
604
+
605
+ // Check if file exists - LLM should only edit existing files
606
+ if (!fs.existsSync(fullPath)) {
607
+ debugLog("REJECTED: File not found", { filePath: mod.filePath });
608
+ return NextResponse.json(
609
+ {
610
+ success: false,
611
+ error: `File not found: ${mod.filePath}. The file may have been moved or deleted.`,
612
+ } as VisionEditResponse,
613
+ { status: 400 }
614
+ );
615
+ }
616
+
617
+ // If file wasn't in our known context, log it (LLM identified it from screenshot)
618
+ if (!knownPaths.has(mod.filePath)) {
619
+ debugLog("LLM identified file not in import chain - trusting its judgment", {
620
+ filePath: mod.filePath,
621
+ exists: true
622
+ });
623
+ }
429
624
  }
430
625
 
431
626
  // Process modifications - apply patches to get modified content
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonance-brand-mcp",
3
- "version": "1.3.33",
3
+ "version": "1.3.36",
4
4
  "description": "MCP Server for Sonance Brand Guidelines and Component Library - gives Claude instant access to brand colors, typography, and UI components.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",