@muloka/figma-console-mcp 0.2.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.
- package/README.md +32 -15
- package/dist/apps/design-system-dashboard/mcp-app.html +521 -0
- package/dist/apps/token-browser/mcp-app.html +410 -0
- package/dist/cloudflare/core/figma-tools.js +64 -13
- package/dist/cloudflare/core/tokens-tools.js +1 -1
- package/dist/cloudflare/core/websocket-server.js +60 -7
- package/dist/cloudflare/index.js +3 -3
- package/dist/core/port-discovery.d.ts.map +1 -1
- package/dist/core/port-discovery.js +101 -20
- package/dist/core/port-discovery.js.map +1 -1
- package/dist/core/tokens-tools.js +1 -1
- package/dist/core/websocket-server.d.ts +41 -2
- package/dist/core/websocket-server.d.ts.map +1 -1
- package/dist/core/websocket-server.js +103 -9
- package/dist/core/websocket-server.js.map +1 -1
- package/dist/core/write-tools.d.ts.map +1 -1
- package/dist/core/write-tools.js +34 -0
- package/dist/core/write-tools.js.map +1 -1
- package/dist/local.d.ts.map +1 -1
- package/dist/local.js +30 -10
- package/dist/local.js.map +1 -1
- package/figma-desktop-bridge/ui.html +66 -5
- package/package.json +2 -2
- package/dist/browser/base.d.ts +0 -58
- package/dist/browser/base.d.ts.map +0 -1
- package/dist/browser/base.js +0 -6
- package/dist/browser/base.js.map +0 -1
- package/dist/browser/local.d.ts +0 -87
- package/dist/browser/local.d.ts.map +0 -1
- package/dist/browser/local.js +0 -318
- package/dist/browser/local.js.map +0 -1
- package/dist/core/figma-desktop-connector.d.ts +0 -312
- package/dist/core/figma-desktop-connector.d.ts.map +0 -1
- package/dist/core/figma-desktop-connector.js +0 -1298
- 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
|
-
.
|
|
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
|
-
.
|
|
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 (
|
|
1441
|
-
const startIdx = (
|
|
1442
|
-
const endIdx = startIdx +
|
|
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 /
|
|
1478
|
+
totalPages: Math.ceil(totalVars / restPageSize),
|
|
1449
1479
|
hasNextPage: endIdx < totalVars,
|
|
1450
|
-
hasPrevPage:
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
864
|
-
|
|
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) => {
|
package/dist/cloudflare/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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://
|
|
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
|
-
//
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
//
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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++;
|