@muloka/figma-console-mcp 0.3.0 → 0.4.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.
Files changed (34) hide show
  1. package/README.md +29 -13
  2. package/dist/apps/design-system-dashboard/mcp-app.html +521 -0
  3. package/dist/apps/token-browser/mcp-app.html +410 -0
  4. package/dist/cloudflare/core/figma-tools.js +64 -13
  5. package/dist/cloudflare/core/tokens-tools.js +1 -1
  6. package/dist/cloudflare/core/websocket-server.js +60 -7
  7. package/dist/cloudflare/index.js +3 -3
  8. package/dist/core/port-discovery.d.ts.map +1 -1
  9. package/dist/core/port-discovery.js +101 -20
  10. package/dist/core/port-discovery.js.map +1 -1
  11. package/dist/core/websocket-server.d.ts +18 -1
  12. package/dist/core/websocket-server.d.ts.map +1 -1
  13. package/dist/core/websocket-server.js +43 -2
  14. package/dist/core/websocket-server.js.map +1 -1
  15. package/dist/core/write-tools.d.ts.map +1 -1
  16. package/dist/core/write-tools.js +34 -0
  17. package/dist/core/write-tools.js.map +1 -1
  18. package/dist/local.d.ts.map +1 -1
  19. package/dist/local.js +7 -3
  20. package/dist/local.js.map +1 -1
  21. package/figma-desktop-bridge/ui.html +66 -5
  22. package/package.json +1 -1
  23. package/dist/browser/base.d.ts +0 -58
  24. package/dist/browser/base.d.ts.map +0 -1
  25. package/dist/browser/base.js +0 -6
  26. package/dist/browser/base.js.map +0 -1
  27. package/dist/browser/local.d.ts +0 -87
  28. package/dist/browser/local.d.ts.map +0 -1
  29. package/dist/browser/local.js +0 -318
  30. package/dist/browser/local.js.map +0 -1
  31. package/dist/core/figma-desktop-connector.d.ts +0 -312
  32. package/dist/core/figma-desktop-connector.d.ts.map +0 -1
  33. package/dist/core/figma-desktop-connector.js +0 -1298
  34. package/dist/core/figma-desktop-connector.js.map +0 -1
@@ -951,21 +951,24 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
951
951
  .optional()
952
952
  .default(false)
953
953
  .describe("DEPRECATED — setting this to true now raises an explicit error. The Puppeteer-based console parser no longer exists. Open the Figma Console MCP Desktop Bridge plugin in Figma Desktop and call figma_get_variables() without parseFromConsole; the plugin returns full variable data through the WebSocket bridge."),
954
+ // NOTE: deliberately no .default() on either param. Every consumer below
955
+ // applies its own fallback (?? 1 / ?? 50), and leaving these undefined is
956
+ // what lets format='full' tell "caller asked for page 2" apart from
957
+ // "caller passed nothing" — without that distinction, honoring them would
958
+ // silently truncate every full response to 50 variables.
954
959
  page: z
955
960
  .number()
956
961
  .int()
957
962
  .min(1)
958
963
  .optional()
959
- .default(1)
960
- .describe("Page number for paginated results (1-based). Use when response is too large (>1MB). Each page returns up to 50 variables."),
964
+ .describe("Page number for paginated results (1-based). Use when the response is too large to handle in one call. Applies to format='filtered' and format='full'. Default: 1. IMPORTANT: a page number beyond the last page is CLAMPED to the last page, not rejected — it returns that page's data again with currentPage set to the real last page. Drive iteration off pagination.hasNextPage; a loop that just increments page will repeat the final page forever."),
961
965
  pageSize: z
962
966
  .number()
963
967
  .int()
964
968
  .min(1)
965
969
  .max(100)
966
970
  .optional()
967
- .default(50)
968
- .describe("Number of variables per page (1-100). Default: 50. Smaller values reduce response size."),
971
+ .describe("Number of variables per page. Hard limit 100 — higher values are rejected with a validation error, not clamped. Default: 50. Applies to format='filtered' and format='full'. For format='full', pagination is applied only when page or pageSize is explicitly passed; otherwise the complete dataset is returned. SOFT LIMIT: a page that exceeds ~25K estimated tokens is auto-summarized, so the page is discarded rather than returned — measured on a 689-variable file, pageSize=100 reached ~16K tokens at standard verbosity and ~20K with verbosity='full' plus resolveAliases. 100 is safe at roughly 200 tokens/variable; prefer 50 when resolving aliases on a mode-heavy file."),
969
972
  resolveAliases: z
970
973
  .boolean()
971
974
  .optional()
@@ -1199,6 +1202,22 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
1199
1202
  }
1200
1203
  else {
1201
1204
  // format === 'full'
1205
+ // Honor page/pageSize when the caller explicitly passed either one.
1206
+ // Previously they were accepted, silently ignored, and omitted from
1207
+ // the response, so a caller had no way to tell paging had not
1208
+ // happened (southleft/figma-console-mcp#98).
1209
+ //
1210
+ // This runs BEFORE the token estimate on purpose: slicing is exactly
1211
+ // how a caller retrieves a large dataset in pieces that fit under the
1212
+ // limit, so summarizing first would defeat the request.
1213
+ //
1214
+ // Both params are undefined unless passed (no zod .default()), so an
1215
+ // un-paginated full request still returns the complete dataset.
1216
+ if (page !== undefined || pageSize !== undefined) {
1217
+ const paginated = paginateVariables(responseData, page ?? 1, pageSize ?? 50);
1218
+ responseData = paginated.data;
1219
+ paginationInfo = paginated.pagination;
1220
+ }
1202
1221
  // Check if we need to auto-summarize
1203
1222
  const estimatedTokens = estimateTokens(responseData);
1204
1223
  if (estimatedTokens > 25000) {
@@ -1435,19 +1454,30 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
1435
1454
  };
1436
1455
  }
1437
1456
  }
1438
- // Apply pagination if requested
1457
+ // Apply pagination if requested.
1458
+ //
1459
+ // page/pageSize no longer carry zod .default() values (see the note on
1460
+ // the schema), so this branch restates the 1/50 it previously received
1461
+ // implicitly. That keeps the REST path's behavior byte-identical: it
1462
+ // has always paginated to 50 whether or not the caller asked. Reaching
1463
+ // this code requires a Figma Enterprise plan, so it cannot be exercised
1464
+ // from this fork — left deliberately unchanged rather than "fixed"
1465
+ // blind. The divergent field names here vs. paginateVariables() are
1466
+ // southleft/figma-console-mcp#99.
1467
+ const restPage = page ?? 1;
1468
+ const restPageSize = pageSize ?? 50;
1439
1469
  let paginationInfo;
1440
- if (pageSize) {
1441
- const startIdx = (page - 1) * pageSize;
1442
- const endIdx = startIdx + pageSize;
1470
+ if (restPageSize) {
1471
+ const startIdx = (restPage - 1) * restPageSize;
1472
+ const endIdx = startIdx + restPageSize;
1443
1473
  const totalVars = localFormatted.variables.length;
1444
1474
  paginationInfo = {
1445
- page,
1446
- pageSize,
1475
+ page: restPage,
1476
+ pageSize: restPageSize,
1447
1477
  totalItems: totalVars,
1448
- totalPages: Math.ceil(totalVars / pageSize),
1478
+ totalPages: Math.ceil(totalVars / restPageSize),
1449
1479
  hasNextPage: endIdx < totalVars,
1450
- hasPrevPage: page > 1,
1480
+ hasPrevPage: restPage > 1,
1451
1481
  };
1452
1482
  localFormatted.variables = localFormatted.variables.slice(startIdx, endIdx);
1453
1483
  if (includePublished && publishedFormatted) {
@@ -1627,6 +1657,10 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
1627
1657
  }
1628
1658
  // Apply format logic
1629
1659
  let responseData = dataForCache;
1660
+ // Declared out here so the response builder below can see it —
1661
+ // it was previously scoped inside the 'filtered' branch, computed,
1662
+ // and then unreachable at return time.
1663
+ let paginationInfo = null;
1630
1664
  if (format === 'summary') {
1631
1665
  responseData = generateSummary(dataForCache);
1632
1666
  logger.info({ fileKey, estimatedTokens: estimateTokens(responseData) }, 'Generated summary from fetched data');
@@ -1644,7 +1678,6 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
1644
1678
  filteredCount: responseData.variables?.length,
1645
1679
  }, 'Applied filters to fetched data');
1646
1680
  // Apply pagination (CRITICAL - was missing!)
1647
- let paginationInfo = null;
1648
1681
  const paginated = paginateVariables(responseData, page || 1, pageSize || 50);
1649
1682
  responseData = paginated.data;
1650
1683
  paginationInfo = paginated.pagination;
@@ -1694,6 +1727,19 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
1694
1727
  }
1695
1728
  else {
1696
1729
  // format === 'full'
1730
+ // Same fix as the cached path above (southleft#98): honor
1731
+ // page/pageSize when explicitly passed, before the token
1732
+ // estimate, so slicing is what keeps a large dataset under the
1733
+ // limit rather than the summarizer discarding it.
1734
+ //
1735
+ // This branch serves cache-miss requests. It is a separate code
1736
+ // path from the cached one and needed the fix independently —
1737
+ // a live check against a warm cache exercises only the other.
1738
+ if (page !== undefined || pageSize !== undefined) {
1739
+ const paginated = paginateVariables(responseData, page ?? 1, pageSize ?? 50);
1740
+ responseData = paginated.data;
1741
+ paginationInfo = paginated.pagination;
1742
+ }
1697
1743
  // Check if we need to auto-summarize
1698
1744
  const estimatedTokens = estimateTokens(responseData);
1699
1745
  if (estimatedTokens > 25000) {
@@ -1773,6 +1819,11 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
1773
1819
  timestamp: dataForCache.timestamp,
1774
1820
  data: responseData,
1775
1821
  cached: false,
1822
+ // paginationInfo was computed above and then dropped on the
1823
+ // floor, so a cache-miss request through the Desktop Bridge
1824
+ // received sliced data with no indication more pages existed
1825
+ // — silent truncation. Same shape the cache path returns.
1826
+ ...(paginationInfo && { pagination: paginationInfo }),
1776
1827
  }),
1777
1828
  },
1778
1829
  ],
@@ -35,7 +35,7 @@ const logger = createChildLogger({ component: "tokens-tools" });
35
35
  * on every exported token document. Kept in sync with package.json by
36
36
  * scripts/release.sh — see step 3 of the release flow.
37
37
  */
38
- const MCP_VERSION = "1.35.0";
38
+ const MCP_VERSION = "1.36.0";
39
39
  const EXPORT_TOOL_DESCRIPTION = `Export Figma variables to design token files in your codebase. Bidirectional with figma_import_tokens — together they replace Style Dictionary and Tokens Studio's export pipeline for the popular styling methods.
40
40
 
41
41
  FULLY-IMPLEMENTED OUTPUT FORMATS:
@@ -75,6 +75,14 @@ export class FigmaWebSocketServer extends EventEmitter {
75
75
  this._pendingClients = new Map();
76
76
  /** The fileKey of the currently active (targeted) file */
77
77
  this._activeFileKey = null;
78
+ /**
79
+ * When true, the active file is pinned: new connections, reconnects, and
80
+ * user interaction (selection/page changes) will NOT move the active target.
81
+ * Lets the agent work in one file while the user works in another without
82
+ * commands silently routing to the wrong file. Cleared if the locked file
83
+ * disconnects.
84
+ */
85
+ this._targetLocked = false;
78
86
  this.pendingRequests = new Map();
79
87
  this.requestIdCounter = 0;
80
88
  this._isStarted = false;
@@ -376,7 +384,8 @@ export class FigmaWebSocketServer extends EventEmitter {
376
384
  if (found) {
377
385
  found.client.selection = message.data;
378
386
  found.client.lastActivity = Date.now();
379
- this._activeFileKey = found.fileKey;
387
+ if (!this._targetLocked)
388
+ this._activeFileKey = found.fileKey;
380
389
  }
381
390
  this.emit('selectionChange', { fileKey: found?.fileKey ?? null, ...message.data });
382
391
  }
@@ -387,7 +396,8 @@ export class FigmaWebSocketServer extends EventEmitter {
387
396
  found.client.fileInfo.currentPage = message.data.pageName;
388
397
  found.client.fileInfo.currentPageId = message.data.pageId || null;
389
398
  found.client.lastActivity = Date.now();
390
- this._activeFileKey = found.fileKey;
399
+ if (!this._targetLocked)
400
+ this._activeFileKey = found.fileKey;
391
401
  }
392
402
  this.emit('pageChange', { fileKey: found?.fileKey ?? null, ...message.data });
393
403
  }
@@ -440,6 +450,9 @@ export class FigmaWebSocketServer extends EventEmitter {
440
450
  this.clients.delete(previousEntry.fileKey);
441
451
  if (this._activeFileKey === previousEntry.fileKey) {
442
452
  this._activeFileKey = null;
453
+ // The pinned plugin navigated to a different file — the old pin is
454
+ // meaningless, so release it rather than silently re-pinning the new file.
455
+ this._targetLocked = false;
443
456
  }
444
457
  // In-flight commands to the old file can never receive a response now —
445
458
  // reject them immediately instead of leaving them to hang until timeout
@@ -514,7 +527,14 @@ export class FigmaWebSocketServer extends EventEmitter {
514
527
  // Most recently connected file becomes active (user just opened the plugin there).
515
528
  // On bulk reconnect the order is non-deterministic, but the first user interaction
516
529
  // (SELECTION_CHANGE or PAGE_CHANGE) will correct the active file immediately.
517
- this._activeFileKey = fileKey;
530
+ // Skip when the target is pinned so a new or reconnecting plugin can't steal it.
531
+ // Invariant: a locked target always has an active file — every path that nulls
532
+ // _activeFileKey also releases the lock — so there is no "locked but nothing
533
+ // active" case to re-adopt here, and a crash-reconnect within the grace window
534
+ // keeps its active key (the grace timer is cleared before it can fire).
535
+ if (!this._targetLocked) {
536
+ this._activeFileKey = fileKey;
537
+ }
518
538
  logger.info({
519
539
  fileName: data.fileName,
520
540
  fileKey,
@@ -556,9 +576,15 @@ export class FigmaWebSocketServer extends EventEmitter {
556
576
  if (current && current.ws === ws) {
557
577
  this.clients.delete(fileKey);
558
578
  this.rejectPendingRequestsForFile(fileKey, 'WebSocket client disconnected');
559
- // If active file disconnected, switch to another connected file
579
+ // If active file disconnected, switch to another connected file.
580
+ // Release any lock — the pinned file is gone, so honoring the lock
581
+ // would strand every command against a dead connection.
560
582
  if (this._activeFileKey === fileKey) {
561
583
  this._activeFileKey = null;
584
+ if (this._targetLocked) {
585
+ this._targetLocked = false;
586
+ logger.info({ fileKey }, 'Target lock released — locked file disconnected');
587
+ }
562
588
  for (const [fk, c] of this.clients) {
563
589
  if (c.ws.readyState === WebSocket.OPEN) {
564
590
  this._activeFileKey = fk;
@@ -855,13 +881,19 @@ export class FigmaWebSocketServer extends EventEmitter {
855
881
  }
856
882
  /**
857
883
  * Set the active file by fileKey. Returns true if the file is connected.
884
+ *
885
+ * Pass `lock: true` to pin the target so subsequent connections, reconnects,
886
+ * and user interaction won't move it. Any explicit switch resets the lock to
887
+ * the value passed here (default: unlocked), so switching without `lock`
888
+ * releases a prior lock.
858
889
  */
859
- setActiveFile(fileKey) {
890
+ setActiveFile(fileKey, lock = false) {
860
891
  const client = this.clients.get(fileKey);
861
892
  if (client && client.ws.readyState === WebSocket.OPEN) {
862
893
  this._activeFileKey = fileKey;
863
- logger.info({ fileKey, fileName: client.fileInfo.fileName }, 'Active file switched');
864
- this.emit('activeFileChanged', { fileKey, fileName: client.fileInfo.fileName });
894
+ this._targetLocked = lock;
895
+ logger.info({ fileKey, fileName: client.fileInfo.fileName, locked: lock }, 'Active file switched');
896
+ this.emit('activeFileChanged', { fileKey, fileName: client.fileInfo.fileName, locked: lock });
865
897
  return true;
866
898
  }
867
899
  return false;
@@ -872,6 +904,26 @@ export class FigmaWebSocketServer extends EventEmitter {
872
904
  getActiveFileKey() {
873
905
  return this._activeFileKey;
874
906
  }
907
+ /**
908
+ * Pin the current active file so connections, reconnects, and user
909
+ * interaction won't move the target. Returns false if no file is active.
910
+ */
911
+ lockTarget() {
912
+ if (!this._activeFileKey)
913
+ return false;
914
+ this._targetLocked = true;
915
+ logger.info({ fileKey: this._activeFileKey }, 'Target locked');
916
+ return true;
917
+ }
918
+ /** Release the target lock; the active file resumes following user activity. */
919
+ unlockTarget() {
920
+ this._targetLocked = false;
921
+ logger.info({ fileKey: this._activeFileKey }, 'Target unlocked');
922
+ }
923
+ /** Whether the active file is currently pinned. */
924
+ isTargetLocked() {
925
+ return this._targetLocked;
926
+ }
875
927
  /**
876
928
  * Get the editor type of the currently active file.
877
929
  * Returns 'figma' if no file is connected or editorType wasn't reported.
@@ -937,6 +989,7 @@ export class FigmaWebSocketServer extends EventEmitter {
937
989
  }
938
990
  this.clients.clear();
939
991
  this._activeFileKey = null;
992
+ this._targetLocked = false;
940
993
  // Close WS server first (handles WebSocket connections)
941
994
  if (this.wss) {
942
995
  await new Promise((resolve) => {
@@ -75,7 +75,7 @@ export class FigmaConsoleMCPv3 extends McpAgent {
75
75
  this.server = (() => {
76
76
  const s = new McpServer({
77
77
  name: "Figma Console MCP",
78
- version: "1.35.0",
78
+ version: "1.36.0",
79
79
  });
80
80
  // Identity wrap — every tool's response and thrown error gets stamped
81
81
  // with our MCP name so cross-MCP attribution is unambiguous.
@@ -1085,7 +1085,7 @@ export default {
1085
1085
  });
1086
1086
  const statelessServer = new McpServer({
1087
1087
  name: "Figma Console MCP",
1088
- version: "1.35.0",
1088
+ version: "1.36.0",
1089
1089
  });
1090
1090
  wrapServerForIdentity(statelessServer);
1091
1091
  // ================================================================
@@ -1723,7 +1723,7 @@ export default {
1723
1723
  return new Response(JSON.stringify({
1724
1724
  status: "healthy",
1725
1725
  service: "Figma Console MCP",
1726
- version: "1.35.0",
1726
+ version: "1.36.0",
1727
1727
  endpoints: {
1728
1728
  mcp: ["/sse", "/mcp"],
1729
1729
  oauth_mcp_spec: ["/.well-known/oauth-authorization-server", "/authorize", "/token", "/oauth/register"],
@@ -1 +1 @@
1
- {"version":3,"file":"port-discovery.d.ts","sourceRoot":"","sources":["../../src/core/port-discovery.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAaH,uCAAuC;AACvC,eAAO,MAAM,eAAe,OAAO,CAAC;AAEpC,mEAAmE;AACnE,eAAO,MAAM,eAAe,KAAK,CAAC;AAQlC,qFAAqF;AACrF,eAAO,MAAM,oBAAoB,QAAqB,CAAC;AAEvD,yFAAyF;AACzF,eAAO,MAAM,kBAAkB,QAAgB,CAAC;AAEhD,mEAAmE;AACnE,eAAO,MAAM,kBAAkB,MAAM,CAAC;AAEtC;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,QAAY,CAAC;AAE3C,uDAAuD;AACvD,eAAO,MAAM,gBAAgB,QAAgB,CAAC;AAE9C,+EAA+E;AAC/E,eAAO,MAAM,mBAAmB,QAAgB,CAAC;AAEjD,wDAAwD;AACxD,eAAO,MAAM,qBAAqB,QAAY,CAAC;AAE/C,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,aAAa,GAAE,MAAwB,GAAG,MAAM,EAAE,CAM9E;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,MAAoB,GAAG,IAAI,CAiB5E;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAa3D;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAUlD;AAcD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAY3D;AAED;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,QAA4B,CAAC;AA6NtE;;;GAGG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAoB9D;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,aAAa,GAAE,MAAwB,GAAG,YAAY,EAAE,CAW/F;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,IAAI,MAAM,CAyD9C;AAED;;;;;;;;GAQG;AACH,wBAAsB,0BAA0B,IAAI,OAAO,CAAC,MAAM,CAAC,CAyDlE;AAED;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CACtC,aAAa,GAAE,MAAwB,EACvC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAClC,MAAM,CAiFR;AAiDD;;;;;;;;GAQG;AACH,wBAAsB,6BAA6B,CACjD,aAAa,GAAE,MAAwB,EACvC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAClC,OAAO,CAAC,MAAM,CAAC,CA4DjB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,mBAAmB,CAAC,aAAa,GAAE,MAAwB,GAAG,OAAO,CA0EpF;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAStD;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,mBAAmB,CAAC,aAAa,GAAE,MAAwB,GAAG,MAAM,IAAI,CAgCvF"}
1
+ {"version":3,"file":"port-discovery.d.ts","sourceRoot":"","sources":["../../src/core/port-discovery.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAaH,uCAAuC;AACvC,eAAO,MAAM,eAAe,OAAO,CAAC;AAEpC,mEAAmE;AACnE,eAAO,MAAM,eAAe,KAAK,CAAC;AAQlC,qFAAqF;AACrF,eAAO,MAAM,oBAAoB,QAAqB,CAAC;AAEvD,yFAAyF;AACzF,eAAO,MAAM,kBAAkB,QAAgB,CAAC;AAEhD,mEAAmE;AACnE,eAAO,MAAM,kBAAkB,MAAM,CAAC;AAEtC;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,QAAY,CAAC;AAE3C,uDAAuD;AACvD,eAAO,MAAM,gBAAgB,QAAgB,CAAC;AAE9C,+EAA+E;AAC/E,eAAO,MAAM,mBAAmB,QAAgB,CAAC;AAEjD,wDAAwD;AACxD,eAAO,MAAM,qBAAqB,QAAY,CAAC;AAE/C,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,aAAa,GAAE,MAAwB,GAAG,MAAM,EAAE,CAM9E;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,MAAoB,GAAG,IAAI,CAkB5E;AA0BD;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAsB3D;AAaD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAalD;AAcD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAY3D;AAED;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,QAA4B,CAAC;AAwOtE;;;GAGG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAoB9D;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,aAAa,GAAE,MAAwB,GAAG,YAAY,EAAE,CAW/F;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,IAAI,MAAM,CA+D9C;AAED;;;;;;;;GAQG;AACH,wBAAsB,0BAA0B,IAAI,OAAO,CAAC,MAAM,CAAC,CA+DlE;AAED;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CACtC,aAAa,GAAE,MAAwB,EACvC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAClC,MAAM,CA4FR;AAiDD;;;;;;;;GAQG;AACH,wBAAsB,6BAA6B,CACjD,aAAa,GAAE,MAAwB,EACvC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAClC,OAAO,CAAC,MAAM,CAAC,CA0EjB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,mBAAmB,CAAC,aAAa,GAAE,MAAwB,GAAG,OAAO,CA0EpF;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAStD;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,mBAAmB,CAAC,aAAa,GAAE,MAAwB,GAAG,MAAM,IAAI,CAgCvF"}
@@ -29,7 +29,7 @@
29
29
  * Plugin scans ports 9223-9232 → connects to first responding server
30
30
  * External tools read port files for discovery
31
31
  */
32
- import { writeFileSync, readFileSync, unlinkSync, existsSync, readdirSync } from 'fs';
32
+ import { writeFileSync, readFileSync, unlinkSync, existsSync, readdirSync, renameSync } from 'fs';
33
33
  import { join } from 'path';
34
34
  import { tmpdir, devNull } from 'os';
35
35
  import { execFile, execFileSync } from 'child_process';
@@ -101,13 +101,41 @@ export function advertisePort(port, host = 'localhost') {
101
101
  };
102
102
  const filePath = getPortFilePath(port);
103
103
  try {
104
- writeFileSync(filePath, JSON.stringify(data, null, 2));
104
+ writePortFileAtomic(filePath, data);
105
+ advertisedPorts.add(port);
105
106
  logger.info({ port, filePath }, 'Port advertised');
106
107
  }
107
108
  catch (error) {
108
109
  logger.warn({ port, filePath, error }, 'Failed to write port advertisement file');
109
110
  }
110
111
  }
112
+ /**
113
+ * Write a port file atomically: full write to a PID-unique temp file, then
114
+ * `renameSync` onto the target. `rename(2)` is atomic on POSIX, so a reader
115
+ * only ever sees the complete old file or the complete new one.
116
+ *
117
+ * A plain `writeFileSync` truncates first, leaving a window where readers see
118
+ * an empty or partial file. That window is re-opened every 30s per instance
119
+ * by the heartbeat, while every sibling scans all ten files — and a reader
120
+ * that hits it gets a JSON parse error, which the cleanup paths used to treat
121
+ * as "corrupt, delete it" and the orphan reaper treats as "no port file =
122
+ * orphan, kill it". A transient read blip must never be able to strand or
123
+ * kill a healthy server.
124
+ */
125
+ function writePortFileAtomic(filePath, data) {
126
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
127
+ try {
128
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2));
129
+ renameSync(tmpPath, filePath);
130
+ }
131
+ catch (error) {
132
+ try {
133
+ unlinkSync(tmpPath);
134
+ }
135
+ catch { /* best-effort */ }
136
+ throw error;
137
+ }
138
+ }
111
139
  /**
112
140
  * Refresh the lastSeen timestamp in a port advertisement file.
113
141
  * Called periodically as a heartbeat to prove this server is still active.
@@ -116,26 +144,47 @@ export function advertisePort(port, host = 'localhost') {
116
144
  export function refreshPortAdvertisement(port) {
117
145
  const filePath = getPortFilePath(port);
118
146
  try {
119
- if (!existsSync(filePath))
147
+ if (!existsSync(filePath)) {
148
+ // Our file is gone but we are still running and still hold the port.
149
+ // Returning here (the old behaviour) left the process permanently
150
+ // unadvertised — and an unadvertised process holding a port is exactly
151
+ // what the orphan reaper terminates. Re-advertise instead so the state
152
+ // is self-healing rather than terminal. Guarded by ownsPort so we never
153
+ // resurrect a file for a port that has since been taken by someone else.
154
+ if (ownsPort(port))
155
+ advertisePort(port);
120
156
  return;
157
+ }
121
158
  const raw = readFileSync(filePath, 'utf-8');
122
159
  const data = JSON.parse(raw);
123
160
  // Only refresh our own port file
124
161
  if (data.pid !== process.pid)
125
162
  return;
126
163
  data.lastSeen = new Date().toISOString();
127
- writeFileSync(filePath, JSON.stringify(data, null, 2));
164
+ writePortFileAtomic(filePath, data);
128
165
  }
129
166
  catch {
130
167
  // Best-effort — heartbeat failures are non-fatal
131
168
  }
132
169
  }
170
+ /**
171
+ * Whether THIS process is the one that advertised `port` in this run.
172
+ * Tracked in-process rather than re-derived from the filesystem, because the
173
+ * filesystem is precisely the state we may be repairing.
174
+ */
175
+ const advertisedPorts = new Set();
176
+ function ownsPort(port) {
177
+ return advertisedPorts.has(port);
178
+ }
133
179
  /**
134
180
  * Remove the port advertisement file for this instance.
135
181
  * Call on clean shutdown.
136
182
  */
137
183
  export function unadvertisePort(port) {
138
184
  const filePath = getPortFilePath(port);
185
+ // Drop ownership first so a heartbeat racing this shutdown can't re-advertise
186
+ // the file we are deliberately removing.
187
+ advertisedPorts.delete(port);
139
188
  try {
140
189
  if (existsSync(filePath)) {
141
190
  unlinkSync(filePath);
@@ -208,9 +257,20 @@ function isValidProbePort(port) {
208
257
  /** curl arguments for the /health liveness probe against a sibling's port.
209
258
  * `devNull` (not a literal /dev/null) — Windows ships curl.exe, and a bad
210
259
  * output path there makes curl exit non-zero, which reads as "nothing
211
- * responding" and would let the reaper kill a healthy sibling. */
260
+ * responding" and would let the reaper kill a healthy sibling.
261
+ *
262
+ * The host MUST be `localhost`, not `127.0.0.1`. The WebSocket server binds
263
+ * `options.host || 'localhost'`, and on a dual-stack macOS box Node resolves
264
+ * that to the IPv6 loopback — `lsof` shows `[::1]:9223`, with nothing
265
+ * listening on IPv4. Probing `127.0.0.1` therefore returned connection-refused
266
+ * for every *healthy* server, which `probeServerHealth` maps to `false` =
267
+ * "confirmed dead". That inverted every kill-safety gate below: the probe that
268
+ * exists to spare live-but-stale siblings was instead rubber-stamping their
269
+ * termination. Verified live: on a working server, `127.0.0.1` gave curl exit
270
+ * 7 while `localhost` gave exit 0. `localhost` lets curl try every resolved
271
+ * address (both families), so it works regardless of which stack Node picked. */
212
272
  function healthProbeArgs(port) {
213
- return ['-s', '-o', devNull, '-m', '1', `http://127.0.0.1:${port}/health`];
273
+ return ['-s', '-o', devNull, '-m', '1', `http://localhost:${port}/health`];
214
274
  }
215
275
  /**
216
276
  * Liveness probe against a sibling server's HTTP `/health` endpoint (the
@@ -507,12 +567,14 @@ export function cleanupStalePortFiles() {
507
567
  }
508
568
  }
509
569
  catch {
510
- // Corrupt file — remove it
511
- try {
512
- unlinkSync(filePath);
513
- cleaned++;
514
- }
515
- catch { /* ignore */ }
570
+ // Unreadable/unparseable — do NOT delete. A partial read of a file
571
+ // mid-rewrite is indistinguishable from genuine corruption, and
572
+ // deleting it strands a healthy server: it becomes a port-holder
573
+ // with no port file, which the orphan reaper terminates on sight.
574
+ // Writes are atomic now (writePortFileAtomic), so this should be
575
+ // unreachable in practice; leaving the file alone means the worst
576
+ // case is a stale file that the dead-PID branch reclaims later.
577
+ logger.debug({ filePath }, 'Unreadable port file — leaving in place');
516
578
  }
517
579
  }
518
580
  }
@@ -574,12 +636,14 @@ export async function cleanupStalePortFilesAsync() {
574
636
  }
575
637
  }
576
638
  catch {
577
- // Corrupt file — remove it
578
- try {
579
- unlinkSync(filePath);
580
- cleaned++;
581
- }
582
- catch { /* ignore */ }
639
+ // Unreadable/unparseable — do NOT delete. A partial read of a file
640
+ // mid-rewrite is indistinguishable from genuine corruption, and
641
+ // deleting it strands a healthy server: it becomes a port-holder
642
+ // with no port file, which the orphan reaper terminates on sight.
643
+ // Writes are atomic now (writePortFileAtomic), so this should be
644
+ // unreachable in practice; leaving the file alone means the worst
645
+ // case is a stale file that the dead-PID branch reclaims later.
646
+ logger.debug({ filePath }, 'Unreadable port file — leaving in place');
583
647
  }
584
648
  }
585
649
  }
@@ -642,7 +706,14 @@ export function cleanupOrphanedProcesses(preferredPort = DEFAULT_WS_PORT, option
642
706
  if (minAgeMs > 0 && ageMs !== null && ageMs < minAgeMs) {
643
707
  continue;
644
708
  }
645
- logger.info({ port, pid, command: cmdline.substring(0, 120) }, 'Terminating orphaned MCP server (no port file, holding port)');
709
+ // See the async path: a missing port file is not proof of death.
710
+ // Only a definitive probe failure authorises termination.
711
+ const health = probeServerHealth(port);
712
+ if (health !== false) {
713
+ logger.debug({ port, pid, probe: health === true ? 'responding' : 'inconclusive' }, 'Port-holder without a port file is still serving /health — skipping kill');
714
+ continue;
715
+ }
716
+ logger.info({ port, pid, command: cmdline.substring(0, 120) }, 'Terminating orphaned MCP server (no port file, holding port, health probe failed)');
646
717
  // terminateProcess escalates SIGTERM -> SIGKILL. Only count it as
647
718
  // cleaned when the process is confirmed gone, so the log reflects
648
719
  // reality (the old code counted attempts and reported success even
@@ -758,7 +829,17 @@ export async function cleanupOrphanedProcessesAsync(preferredPort = DEFAULT_WS_P
758
829
  if (minAgeMs > 0 && ageMs !== null && ageMs < minAgeMs) {
759
830
  continue;
760
831
  }
761
- logger.info({ port, pid, command: cmdline.substring(0, 120) }, 'Terminating orphaned MCP server (no port file, holding port)');
832
+ // A missing port file is not proof of death — it can also mean a
833
+ // transient read failure, or a file removed out from under a healthy
834
+ // server. Ask the server itself before killing it. Same verdict
835
+ // semantics as the stale-file path: only a definitive `false`
836
+ // (probe ran, nothing answered) authorises termination.
837
+ const health = await probeServerHealthAsync(port);
838
+ if (health !== false) {
839
+ logger.debug({ port, pid, probe: health === true ? 'responding' : 'inconclusive' }, 'Port-holder without a port file is still serving /health — re-advertising is its job, not ours; skipping kill');
840
+ continue;
841
+ }
842
+ logger.info({ port, pid, command: cmdline.substring(0, 120) }, 'Terminating orphaned MCP server (no port file, holding port, health probe failed)');
762
843
  // Only count confirmed kills, matching the sync path.
763
844
  if (await terminateProcessAsync(pid)) {
764
845
  cleaned++;