unframer 4.2.0 → 4.2.1

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.
Files changed (66) hide show
  1. package/dist/css.d.ts +1 -1
  2. package/dist/css.d.ts.map +1 -1
  3. package/dist/framer-chunks/fontshare-PZLWRK4B-MHMZIGTX.js +7 -0
  4. package/dist/framer-chunks/fontshare-SXU5BGFE-OWTMMPGS.js +7 -0
  5. package/dist/framer-chunks/fontshare-TIA7QUPT-PUDLUTQ7.js +7 -0
  6. package/dist/framer-chunks/framer-font-RD2SUPQH-Q4MS7WS6.js +7 -0
  7. package/dist/framer-chunks/google-EGNT223R-P4DUHBW2.js +7 -0
  8. package/dist/framer-chunks/google-H6SFY4F5-5JSJCGDR.js +7 -0
  9. package/dist/framer-chunks/google-YSYBFRE6-L7YAHH7V.js +7 -0
  10. package/dist/framer.js +15625 -10915
  11. package/dist/plugin-mcp-dist/lib/framer-client.server.d.ts.map +1 -1
  12. package/dist/plugin-mcp-dist/lib/framer-client.server.js +9 -0
  13. package/dist/plugin-mcp-dist/lib/framer-client.server.js.map +1 -1
  14. package/dist/plugin-mcp-dist/lib/framer.d.ts +8 -1
  15. package/dist/plugin-mcp-dist/lib/framer.d.ts.map +1 -1
  16. package/dist/plugin-mcp-dist/lib/framer.js +87 -7
  17. package/dist/plugin-mcp-dist/lib/framer.js.map +1 -1
  18. package/dist/plugin-mcp-dist/lib/framer.test.js +45 -1
  19. package/dist/plugin-mcp-dist/lib/framer.test.js.map +1 -1
  20. package/dist/plugin-mcp-dist/lib/mcp-handlers.d.ts +12 -12
  21. package/dist/plugin-mcp-dist/lib/mcp-handlers.d.ts.map +1 -1
  22. package/dist/plugin-mcp-dist/lib/mcp-handlers.js +190 -56
  23. package/dist/plugin-mcp-dist/lib/mcp-handlers.js.map +1 -1
  24. package/dist/plugin-mcp-dist/lib/plugin-websocket.d.ts.map +1 -1
  25. package/dist/plugin-mcp-dist/lib/plugin-websocket.js +36 -25
  26. package/dist/plugin-mcp-dist/lib/plugin-websocket.js.map +1 -1
  27. package/dist/plugin-mcp-dist/lib/utils.d.ts +1 -1
  28. package/dist/plugin-mcp-dist/lib/utils.d.ts.map +1 -1
  29. package/dist/react.d.ts +3 -3
  30. package/dist/react.d.ts.map +1 -1
  31. package/dist/version.d.ts +1 -1
  32. package/dist/version.js +1 -1
  33. package/package.json +3 -2
  34. package/src/framer.js +15625 -10915
  35. package/src/plugin-mcp-dist/lib/framer-client.server.d.ts.map +1 -1
  36. package/src/plugin-mcp-dist/lib/framer-client.server.js +9 -0
  37. package/src/plugin-mcp-dist/lib/framer-client.server.js.map +1 -1
  38. package/src/plugin-mcp-dist/lib/framer.d.ts +10 -1
  39. package/src/plugin-mcp-dist/lib/framer.d.ts.map +1 -1
  40. package/src/plugin-mcp-dist/lib/framer.js +87 -7
  41. package/src/plugin-mcp-dist/lib/framer.js.map +1 -1
  42. package/src/plugin-mcp-dist/lib/framer.test.js +45 -1
  43. package/src/plugin-mcp-dist/lib/framer.test.js.map +1 -1
  44. package/src/plugin-mcp-dist/lib/mcp-handlers.d.ts +3 -3
  45. package/src/plugin-mcp-dist/lib/mcp-handlers.d.ts.map +1 -1
  46. package/src/plugin-mcp-dist/lib/mcp-handlers.js +190 -56
  47. package/src/plugin-mcp-dist/lib/mcp-handlers.js.map +1 -1
  48. package/src/plugin-mcp-dist/lib/plugin-websocket.d.ts.map +1 -1
  49. package/src/plugin-mcp-dist/lib/plugin-websocket.js +36 -25
  50. package/src/plugin-mcp-dist/lib/plugin-websocket.js.map +1 -1
  51. package/src/plugin-mcp-dist/lib/react-export.d.ts +1 -1
  52. package/src/plugin-mcp-dist/lib/schema.d.ts +3 -3
  53. package/src/styles/framer.css +2 -56
  54. package/src/version.ts +1 -1
  55. package/dist/framer-chunks/SqliteDatabase-VAKIICSG-OPV4MG3Y.js +0 -98
  56. package/dist/framer-chunks/chunk-7ZLQTYXQ.js +0 -102
  57. package/dist/framer-chunks/default-blog-sqlite-7ZHEY3GT-TVGUYU6H.js +0 -7
  58. package/dist/framer-chunks/fontshare-4THNDPMZ-FLLTWCDO.js +0 -7
  59. package/dist/framer-chunks/fontshare-B2QLD7YB-5V3XZJAH.js +0 -7
  60. package/dist/framer-chunks/fontshare-O22OBJ3D-FIG3CRN3.js +0 -7
  61. package/dist/framer-chunks/framer-font-45AI7UCZ-CKGC4MJC.js +0 -7
  62. package/dist/framer-chunks/google-3FCAKCAC-AM34UPJK.js +0 -7
  63. package/dist/framer-chunks/google-3SZHWBC6-2VTQEQ7J.js +0 -7
  64. package/dist/framer-chunks/google-GXDJLGJB-JNEJGCGD.js +0 -7
  65. package/dist/framer-chunks/sqlite-wasm-FGP37EAY-NO5QVZDI.js +0 -7
  66. package/dist/framer-chunks/sqlite3-SISQ6ENZ-5WRKGWTY.js +0 -7
@@ -7,7 +7,7 @@ import dedent from 'string-dedent';
7
7
  import { createPatch } from 'diff';
8
8
  import { createSpiceflowFetch } from 'spiceflow/client';
9
9
  import { framerLayersTreeToXml, extractObjectsFromXmlContent, TEMP_NODE_ID_PREFIX, } from './xml.js';
10
- import { getFramerTree, applyAttributes, getComponentPropertyControls } from './framer.js';
10
+ import { getFramerTree, applyAttributes, getComponentPropertyControls, createNodeResolver, createChildIndex, } from './framer.js';
11
11
  import { processReactExportData } from './react-export.js';
12
12
  import { propControlsToTypedocComments, componentCamelCase } from 'unframer';
13
13
  import { codeComponentsResourceUri, mcpTools } from './schema.js';
@@ -186,8 +186,8 @@ function stripVersionFromUrl(url) {
186
186
  return atIndex !== -1 ? url.substring(0, atIndex) : url;
187
187
  }
188
188
  // Helper function to get XML for a node
189
- async function getNodeXml(nodeId, maxCharacters = 15000) {
190
- const node = await framer.getNode(nodeId);
189
+ async function getNodeXml(nodeId, maxCharacters = 15000, resolver = createNodeResolver()) {
190
+ const node = await resolver.get(nodeId);
191
191
  if (!node) {
192
192
  return null;
193
193
  }
@@ -217,13 +217,52 @@ async function getAddedNodesDuring(callback) {
217
217
  const newNodes = childrenAfter.filter((child) => !idsBefore.has(child.id));
218
218
  return newNodes;
219
219
  }
220
- // Helper function to create a new Framer node based on its type
221
- async function createFramerNode({ extractedNode, parentId, }) {
220
+ // Framer silently ignores structural writes that target a partially loaded scope:
221
+ // `setParent` resolves without moving anything and `createFrameNode(attrs, parentId)`
222
+ // drops the new node under the canvas root instead of the requested parent. Both
223
+ // only work for the page or component currently open on the canvas. Always verify
224
+ // the parent afterwards so the tool reports a real failure instead of pretending.
225
+ async function offScopeHint() {
226
+ const canvasRootId = await framer
227
+ .getCanvasRoot()
228
+ .then((root) => root.id)
229
+ .catch(() => undefined);
230
+ const open = canvasRootId
231
+ ? `the page or component currently open on the canvas (${canvasRootId})`
232
+ : 'the page or component currently open on the canvas';
233
+ return `Framer can only move and create layers inside ${open}, and this node is not in it. Open that page in Framer, then retry.`;
234
+ }
235
+ async function assertParent({ nodeId, parentId, children, }) {
236
+ // Reads the parent fresh: a failed read throws instead of being read as "the move
237
+ // did not land", so callers never destroy or re-move a correctly placed node.
238
+ children.invalidate();
239
+ const childIds = await children.get(parentId);
240
+ if (childIds.includes(nodeId)) {
241
+ return;
242
+ }
243
+ throw new Error(`Framer did not move node ${nodeId} into parent ${parentId}. ${await offScopeHint()}`);
244
+ }
245
+ async function setParentOrThrow({ nodeId, parentId, index, children, }) {
246
+ await framer.setParent(nodeId, parentId, index);
247
+ await assertParent({ nodeId, parentId, children });
248
+ }
249
+ // Helper function to create a new Framer node based on its type.
250
+ // `onCreated` fires as soon as Framer hands back a node id, before parenting and
251
+ // verification, so the caller can roll the node back if a later step throws. Without
252
+ // it a failed setParent leaves an orphan layer on the canvas root.
253
+ async function createFramerNode({ extractedNode, parentId, resolver, children, onCreated, }) {
222
254
  const { nodeType, attributes, newContent } = extractedNode;
223
255
  switch (nodeType) {
224
256
  case 'Frame': {
225
257
  const newFrame = await framer.createFrameNode(attributes, parentId);
226
- return newFrame ? { id: newFrame.id, type: 'Frame' } : null;
258
+ if (!newFrame) {
259
+ return null;
260
+ }
261
+ onCreated(newFrame.id);
262
+ // createFrameNode ignores parentId outside the loaded scope and drops the
263
+ // node on the canvas root instead, so the placement has to be checked.
264
+ await assertParent({ nodeId: newFrame.id, parentId, children });
265
+ return { id: newFrame.id, type: 'Frame' };
227
266
  }
228
267
  case 'Text': {
229
268
  // Text nodes need special handling
@@ -233,11 +272,12 @@ async function createFramerNode({ extractedNode, parentId, }) {
233
272
  });
234
273
  const newNodeId = newNodes[0]?.id;
235
274
  if (newNodeId) {
275
+ onCreated(newNodeId);
236
276
  // Move to correct parent
237
- await framer.setParent(newNodeId, parentId);
277
+ await setParentOrThrow({ nodeId: newNodeId, parentId, children });
238
278
  // Apply attributes if any
239
279
  if (Object.keys(attributes).length > 0) {
240
- const node = await framer.getNode(newNodeId);
280
+ const node = await resolver.get(newNodeId);
241
281
  await applyAttributes(node, attributes);
242
282
  }
243
283
  return { id: newNodeId, type: 'Text' };
@@ -252,14 +292,15 @@ async function createFramerNode({ extractedNode, parentId, }) {
252
292
  });
253
293
  const newNodeId = newNodes[0]?.id;
254
294
  if (newNodeId) {
295
+ onCreated(newNodeId);
255
296
  // Move to correct parent
256
- await framer.setParent(newNodeId, parentId);
297
+ await setParentOrThrow({ nodeId: newNodeId, parentId, children });
257
298
  // Apply remaining attributes
258
299
  const remainingAttrs = { ...attributes };
259
300
  delete remainingAttrs.svg;
260
301
  delete remainingAttrs.name;
261
302
  if (Object.keys(remainingAttrs).length > 0) {
262
- const node = await framer.getNode(newNodeId);
303
+ const node = await resolver.get(newNodeId);
263
304
  await applyAttributes(node, remainingAttrs);
264
305
  }
265
306
  return { id: newNodeId, type: 'SVG' };
@@ -271,7 +312,7 @@ async function createFramerNode({ extractedNode, parentId, }) {
271
312
  // If no insertUrl, try to find it from componentId
272
313
  if (!insertUrl && attributes.componentId) {
273
314
  // First try to get it as a component node
274
- const node = await framer.getNode(attributes.componentId);
315
+ const node = await resolver.get(attributes.componentId);
275
316
  if (node && isComponentNode(node) && node.insertURL) {
276
317
  insertUrl = node.insertURL;
277
318
  }
@@ -328,8 +369,9 @@ async function createFramerNode({ extractedNode, parentId, }) {
328
369
  nodeId = instance.id;
329
370
  nodeType = 'ComponentInstance';
330
371
  }
372
+ onCreated(nodeId);
331
373
  // Move to correct parent
332
- await framer.setParent(nodeId, parentId);
374
+ await setParentOrThrow({ nodeId, parentId, children });
333
375
  return { id: nodeId, type: nodeType };
334
376
  }
335
377
  default:
@@ -356,6 +398,24 @@ function checkPermissions(...methods) {
356
398
  }
357
399
  // Websocket handler function
358
400
  export async function mcpToolHandler({ input, type, }) {
401
+ // Validate input against zod schema before processing.
402
+ // Without this, malformed input (e.g., wrong parameter names, missing fields)
403
+ // causes cryptic runtime errors deep in handler code — like htmlparser2 crashing
404
+ // with "Cannot read properties of undefined (reading 'length')" when the `xml`
405
+ // parameter is missing from updateXmlForNode.
406
+ const tool = mcpTools[type];
407
+ if (tool?.input) {
408
+ const result = tool.input.safeParse(input);
409
+ if (!result.success) {
410
+ const issues = result.error.issues
411
+ .map((i) => {
412
+ return `- ${i.path.join('.')}: ${i.message}`;
413
+ })
414
+ .join('\n');
415
+ return `Invalid input for tool "${type}":\n${issues}\n\nExpected parameters: ${Object.keys(tool.input.shape || {}).join(', ')}`;
416
+ }
417
+ input = result.data;
418
+ }
359
419
  switch (type) {
360
420
  case 'getNodeXml': {
361
421
  const { nodeId } = input;
@@ -568,27 +628,58 @@ export async function mcpToolHandler({ input, type, }) {
568
628
  if (rootNodeId.startsWith('/')) {
569
629
  return `Node ID cannot start with a slash. It should be a valid node ID, not a color style or text path. To update styles use 'manageColorStyle' or 'manageTextStyle' tools.`;
570
630
  }
571
- // Zoom into the node before making changes if requested
572
- if (zoomIntoView) {
631
+ const resolver = createNodeResolver();
632
+ const children = createChildIndex(resolver);
633
+ // Structural writes (setParent, createFrameNode with a parentId) silently
634
+ // do nothing outside the loaded canvas root, and navigating is the only way
635
+ // to load another scope. Only navigate when the target really is out of
636
+ // scope: navigation takes over the page the user is looking at, and Framer
637
+ // may restart the plugin in another mode. A direct getNode hit proves the
638
+ // node is in the loaded scope, so the common case never navigates.
639
+ const isRootInLoadedScope = Boolean(await framer.getNode(rootNodeId));
640
+ if (isRootInLoadedScope) {
641
+ if (zoomIntoView) {
642
+ try {
643
+ await framer.zoomIntoView(rootNodeId, { maxZoom: 0.9 });
644
+ }
645
+ catch (error) {
646
+ // Don't fail the entire operation if zooming fails
647
+ console.warn('Failed to zoom into view:', error);
648
+ }
649
+ }
650
+ }
651
+ else {
652
+ // navigateTo does not exist in server-api mode (no canvas to move), so
653
+ // this is a no-op there and structural writes fail loudly instead.
573
654
  try {
574
- await framer.zoomIntoView(rootNodeId, { maxZoom: 0.9 });
655
+ await framer.navigateTo(rootNodeId, {
656
+ select: false,
657
+ zoomIntoView: zoomIntoView ? { maxZoom: 0.9 } : false,
658
+ });
575
659
  }
576
660
  catch (error) {
577
- // Don't fail the entire operation if zooming fails
578
- console.warn('Failed to zoom into view:', error);
661
+ console.warn('Failed to navigate to node:', error);
579
662
  }
580
663
  }
581
664
  // Get the original XML before making changes
582
- const originalResult = await getNodeXml(rootNodeId, Infinity);
665
+ const originalResult = await getNodeXml(rootNodeId, Infinity, resolver);
583
666
  const originalXml = originalResult?.xml || '';
584
667
  // Extract nodes from the provided XML with node creation enabled
585
668
  const extractedNodes = extractObjectsFromXmlContent(xml, {
586
669
  enableNodeCreation: true,
587
670
  });
588
671
  const results = [];
672
+ // Nodes whose attributes already held the requested values. Kept apart
673
+ // from `results` so the final message can say "nothing to change" instead
674
+ // of blaming made up attributes.
675
+ const alreadyUpToDateNodeIds = [];
589
676
  const nodesToReorder = [];
590
677
  // Phase 0: Create new nodes (nodes with temp IDs)
591
678
  const tempIdToRealId = new Map();
679
+ // Every node Framer actually created, recorded before parenting so a
680
+ // half-created node is still rolled back. tempIdToRealId only gets an entry
681
+ // once creation fully succeeded, so it cannot drive the rollback.
682
+ const createdNodeIds = [];
592
683
  for (const extractedNode of extractedNodes) {
593
684
  // Check if this is a new node (has temp ID)
594
685
  if (extractedNode.nodeId.startsWith(TEMP_NODE_ID_PREFIX)) {
@@ -603,6 +694,11 @@ export async function mcpToolHandler({ input, type, }) {
603
694
  const newNode = await createFramerNode({
604
695
  extractedNode,
605
696
  parentId,
697
+ resolver,
698
+ children,
699
+ onCreated: (nodeId) => {
700
+ createdNodeIds.push(nodeId);
701
+ },
606
702
  });
607
703
  if (newNode) {
608
704
  // Map temp ID to real ID
@@ -642,10 +738,10 @@ export async function mcpToolHandler({ input, type, }) {
642
738
  await framer.notify(`Failed to create ${extractedNode.nodeType || 'node'}: ${errorMessage}`, {
643
739
  variant: 'error',
644
740
  });
645
- // Rollback all created nodes using tempIdToRealId values
646
- for (const nodeId of tempIdToRealId.values()) {
741
+ // Rollback every node Framer created during this call
742
+ for (const nodeId of createdNodeIds) {
647
743
  try {
648
- const node = await framer.getNode(nodeId);
744
+ const node = await resolver.get(nodeId);
649
745
  if (node) {
650
746
  await node.remove();
651
747
  }
@@ -667,9 +763,9 @@ export async function mcpToolHandler({ input, type, }) {
667
763
  }
668
764
  const targetNodeId = extractedNode.nodeId || rootNodeId;
669
765
  try {
670
- const node = await framer.getNode(targetNodeId);
766
+ const node = await resolver.get(targetNodeId);
671
767
  if (!node) {
672
- results.push(`Node with ID ${targetNodeId} not found.`);
768
+ results.push(`Failed to process node ${targetNodeId}: no node with this ID exists in the project.`);
673
769
  continue;
674
770
  }
675
771
  // Update text content for text nodes
@@ -680,17 +776,27 @@ export async function mcpToolHandler({ input, type, }) {
680
776
  // Apply attributes
681
777
  if (extractedNode.attributes &&
682
778
  Object.keys(extractedNode.attributes).length > 0) {
683
- await applyAttributes(node, extractedNode.attributes);
684
- results.push(`Updated attributes for node ${targetNodeId}`);
779
+ const changedKeys = await applyAttributes(node, extractedNode.attributes);
780
+ if (changedKeys.length > 0) {
781
+ results.push(`Updated attributes for node ${targetNodeId}: ${changedKeys.join(', ')}`);
782
+ }
783
+ else {
784
+ alreadyUpToDateNodeIds.push(targetNodeId);
785
+ }
685
786
  }
686
- // Check if parent needs to change
687
- if (extractedNode.parentId && node.getParent) {
688
- const currentParent = await node.getParent();
689
- const currentParentId = currentParent?.id;
690
- if (currentParentId !== extractedNode.parentId) {
787
+ // Check if parent needs to change. A failed children read throws
788
+ // out of this block instead of being read as "not a child", which
789
+ // would move nodes that are already in the right place.
790
+ if (extractedNode.parentId) {
791
+ const siblingIds = await children.get(extractedNode.parentId);
792
+ if (!siblingIds.includes(targetNodeId)) {
691
793
  // Move to new parent without specifying position yet
692
- await framer.setParent(targetNodeId, extractedNode.parentId);
693
- results.push(`Moved node ${targetNodeId} from parent ${currentParentId || 'none'} to ${extractedNode.parentId}`);
794
+ await setParentOrThrow({
795
+ nodeId: targetNodeId,
796
+ parentId: extractedNode.parentId,
797
+ children,
798
+ });
799
+ results.push(`Moved node ${targetNodeId} into ${extractedNode.parentId}`);
694
800
  }
695
801
  // Queue for reordering if sibling info is provided
696
802
  if (extractedNode.beforeNodeId ||
@@ -714,28 +820,25 @@ export async function mcpToolHandler({ input, type, }) {
714
820
  // been moved yet, and index calculations would be incorrect during the moving process.
715
821
  for (const reorderInfo of nodesToReorder) {
716
822
  try {
717
- const parent = await framer.getNode(reorderInfo.parentId);
718
- if (!parent)
719
- continue;
720
- const siblings = await parent.getChildren();
823
+ const siblings = await children.get(reorderInfo.parentId);
721
824
  let targetIndex;
722
825
  if (reorderInfo.beforeNodeId) {
723
826
  // Place after the beforeNode
724
- const beforeIndex = siblings.findIndex((s) => s.id === reorderInfo.beforeNodeId);
827
+ const beforeIndex = siblings.indexOf(reorderInfo.beforeNodeId);
725
828
  if (beforeIndex !== -1) {
726
829
  targetIndex = beforeIndex + 1;
727
830
  }
728
831
  }
729
832
  else if (reorderInfo.afterNodeId) {
730
833
  // Place before the afterNode
731
- const afterIndex = siblings.findIndex((s) => s.id === reorderInfo.afterNodeId);
834
+ const afterIndex = siblings.indexOf(reorderInfo.afterNodeId);
732
835
  if (afterIndex !== -1) {
733
836
  targetIndex = afterIndex;
734
837
  }
735
838
  }
736
839
  if (targetIndex !== undefined) {
737
840
  // Get current index
738
- const currentIndex = siblings.findIndex((s) => s.id === reorderInfo.nodeId);
841
+ const currentIndex = siblings.indexOf(reorderInfo.nodeId);
739
842
  // Only reorder if position needs to change
740
843
  if (currentIndex !== -1 &&
741
844
  currentIndex !== targetIndex) {
@@ -749,7 +852,12 @@ export async function mcpToolHandler({ input, type, }) {
749
852
  if (currentIndex < targetIndex) {
750
853
  targetIndex -= 1;
751
854
  }
752
- await framer.setParent(reorderInfo.nodeId, reorderInfo.parentId, targetIndex);
855
+ await setParentOrThrow({
856
+ nodeId: reorderInfo.nodeId,
857
+ parentId: reorderInfo.parentId,
858
+ index: targetIndex,
859
+ children,
860
+ });
753
861
  results.push(`Reordered node ${reorderInfo.nodeId} within parent ${reorderInfo.parentId} to index ${targetIndex}`);
754
862
  }
755
863
  }
@@ -759,13 +867,19 @@ export async function mcpToolHandler({ input, type, }) {
759
867
  }
760
868
  }
761
869
  // Get the updated XML for the primary node
762
- const updatedResult = await getNodeXml(rootNodeId, Infinity);
870
+ const updatedResult = await getNodeXml(rootNodeId, Infinity, resolver);
763
871
  const updatedXml = updatedResult?.xml || '';
764
872
  // Check if there were actual changes by comparing XML
765
873
  const hasChanges = originalXml.trim() !== updatedXml.trim();
874
+ // Some nodes can land while others fail, so a changed XML alone must not be
875
+ // reported as a full success or the agent stops chasing the failures.
876
+ const hasErrors = results.some((r) => r.startsWith('Failed '));
766
877
  if (hasChanges && updatedResult) {
878
+ const header = hasErrors
879
+ ? 'Partially updated, some operations failed:'
880
+ : 'Successfully updated:';
767
881
  const resultMessage = results.length > 0
768
- ? `Successfully updated:\n${results.join('\n')}`
882
+ ? `${header}\n${results.join('\n')}`
769
883
  : 'Successfully updated';
770
884
  // Create a diff patch showing the changes with more context
771
885
  const patch = createPatch('node.xml', originalXml, updatedXml, 'Before', 'After', { context: 20 });
@@ -775,10 +889,15 @@ export async function mcpToolHandler({ input, type, }) {
775
889
  : '';
776
890
  return `${resultMessage}\n\nXML Changes:\n${patch}${zoomNote}`;
777
891
  }
778
- const hasErrors = results.some((r) => r.startsWith('Failed '));
779
892
  if (hasErrors) {
780
893
  return `Encountered errors while updating:\n${results.join('\n')}`;
781
894
  }
895
+ if (alreadyUpToDateNodeIds.length > 0) {
896
+ return `No changes were made: every attribute already had the requested value on ${alreadyUpToDateNodeIds.join(', ')}. Call getNodeXml to read the current values before deciding what to change.`;
897
+ }
898
+ if (results.length > 0) {
899
+ return `Framer reported these operations but the XML of ${rootNodeId} is unchanged:\n${results.join('\n')}`;
900
+ }
782
901
  return 'No changes were made! Make sure you are not using made up attributes, follow the outlined attributes only.';
783
902
  }
784
903
  case 'zoomIntoView': {
@@ -1045,7 +1164,7 @@ export async function mcpToolHandler({ input, type, }) {
1045
1164
  const permissionError = checkPermissions('Node.remove');
1046
1165
  if (permissionError)
1047
1166
  return permissionError;
1048
- const node = await framer.getNode(nodeId);
1167
+ const node = await createNodeResolver().get(nodeId);
1049
1168
  if (!node) {
1050
1169
  return `Node with ID ${nodeId} not found.`;
1051
1170
  }
@@ -1058,24 +1177,30 @@ export async function mcpToolHandler({ input, type, }) {
1058
1177
  const permissionError = checkPermissions('Node.clone', 'setParent');
1059
1178
  if (permissionError)
1060
1179
  return permissionError;
1061
- const node = await framer.getNode(nodeId);
1180
+ const resolver = createNodeResolver();
1181
+ const node = await resolver.get(nodeId);
1062
1182
  if (!node) {
1063
1183
  return `Node with ID ${nodeId} not found.`;
1064
1184
  }
1065
1185
  try {
1186
+ // getParent() returns null for nodes outside the loaded canvas scope,
1187
+ // and cloning needs that scope anyway, so say which case it is.
1066
1188
  const parent = await node.getParent();
1067
1189
  if (!parent) {
1068
- throw new Error('No parent found for node');
1190
+ throw new Error(`Cannot read the parent of ${nodeId}. ${await offScopeHint()}`);
1069
1191
  }
1070
- let cloned = await node.clone();
1192
+ const cloned = await node.clone();
1071
1193
  if (!cloned) {
1072
1194
  throw new Error('No new node cloned found');
1073
1195
  }
1074
- await framer.setParent(cloned.id, parent.id);
1075
- if (!cloned) {
1076
- return `Failed to duplicate node ${nodeId}: The operation returned null.`;
1077
- }
1078
- return `Here is the new node XML:\n\n` + getNodeXml(cloned.id);
1196
+ const children = createChildIndex(resolver);
1197
+ await setParentOrThrow({
1198
+ nodeId: cloned.id,
1199
+ parentId: parent.id,
1200
+ children,
1201
+ });
1202
+ const clonedXml = await getNodeXml(cloned.id, undefined, resolver);
1203
+ return `Here is the new node XML:\n\n${clonedXml?.xml || ''}`;
1079
1204
  }
1080
1205
  catch (error) {
1081
1206
  return `Failed to duplicate node ${nodeId}: ${error instanceof Error ? error.message : 'Unknown error'}`;
@@ -1242,8 +1367,10 @@ export async function mcpToolHandler({ input, type, }) {
1242
1367
  try {
1243
1368
  // Build array of component info objects
1244
1369
  const components = [];
1245
- // First try as component node
1246
- const node = await framer.getNode(id);
1370
+ // First try as component node. Component IDs come from getProjectXml,
1371
+ // which lists them via getNodesWithType, so they routinely point
1372
+ // outside the loaded canvas scope where framer.getNode returns null.
1373
+ const node = await createNodeResolver().get(id);
1247
1374
  if (node) {
1248
1375
  // Check if it's a component node
1249
1376
  if (!isComponentNode(node)) {
@@ -1810,15 +1937,22 @@ export async function mcpToolHandler({ input, type, }) {
1810
1937
  // Move existing nodes into the component if provided
1811
1938
  if (nodeIds && nodeIds.length > 0) {
1812
1939
  const moveResults = [];
1940
+ const resolver = createNodeResolver();
1941
+ const children = createChildIndex(resolver);
1813
1942
  let targetIndex = 0;
1814
1943
  for (const nodeId of nodeIds) {
1815
1944
  try {
1816
- const node = await framer.getNode(nodeId);
1945
+ const node = await resolver.get(nodeId);
1817
1946
  if (!node) {
1818
1947
  moveResults.push(`Node ${nodeId} not found, skipped`);
1819
1948
  continue;
1820
1949
  }
1821
- await framer.setParent(nodeId, componentId, targetIndex);
1950
+ await setParentOrThrow({
1951
+ nodeId,
1952
+ parentId: componentId,
1953
+ index: targetIndex,
1954
+ children,
1955
+ });
1822
1956
  targetIndex += 1;
1823
1957
  moveResults.push(`Moved node ${nodeId} into component`);
1824
1958
  }