@gtkx/mcp 1.6.0 → 2.0.0-beta.10

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/src/server.ts CHANGED
@@ -12,7 +12,6 @@ import { type AppRegisteredEvent, AppRouter, type AppUnregisteredEvent } from ".
12
12
  import { ConnectionRegistry } from "./connection-registry.js";
13
13
  import {
14
14
  type AppInfo,
15
- DEFAULT_SOCKET_PATH,
16
15
  DEFAULT_SUBTREE_DEPTH,
17
16
  fireEventParams,
18
17
  MAX_SUBTREE_WIDGETS,
@@ -29,6 +28,7 @@ import {
29
28
  type ReferenceProvider,
30
29
  registerReferenceResources,
31
30
  } from "./reference.js";
31
+ import { resolveMcpSocketAddress } from "./socket-path.js";
32
32
  import { SocketServer } from "./socket-server.js";
33
33
  import { selectTools } from "./tool-filter.js";
34
34
  import { defineTool, imageContent, registerTool, textContent, textError, type Tool } from "./tool.js";
@@ -53,9 +53,11 @@ type McpServerHandle = {
53
53
  type ServerLifecycle = {
54
54
  socketServer: SocketServer;
55
55
  mcpServer: McpServer;
56
+ appRouter: AppRouter;
56
57
  socketPath: string;
57
- isStopped: boolean;
58
- isStarted: boolean;
58
+ isStopRequested: boolean;
59
+ startup: Promise<void> | null;
60
+ shutdown: Promise<void> | null;
59
61
  };
60
62
 
61
63
  type AppWindow = { id: string; title: string | null };
@@ -68,21 +70,29 @@ const DEFAULT_SETTINGS: McpSettings = { tools: [], isReadOnly: false };
68
70
  const INSTRUCTIONS =
69
71
  "The widget tools drive a GTKX app running under `gtkx dev`: they read " +
70
72
  "its live widget tree, query it by accessible role and name, click and type, and capture screenshots. " +
71
- "They fail until an app is running, so start `gtkx dev` first. The reference tools answer from the " +
73
+ "They wait briefly for a starting app, so start `gtkx dev` before or alongside a widget request. " +
74
+ "The reference tools answer from the " +
72
75
  "bindings generated for a specific project, so they describe that project's GIR libraries rather than " +
73
- "GTK in general; prefer them over recalled GTK knowledge, which is usually C, PyGObject or GJS and " +
74
- "does not apply here.\n\n" +
76
+ "the GNOME platform in general; prefer them over recalled Adwaita or GTK knowledge, which is usually C, " +
77
+ "PyGObject or GJS and does not apply here.\n\n" +
75
78
  "Widget IDs are valid only while the widget is mounted. After a dialog closes, a list re-renders, or " +
76
79
  "fast refresh patches a component, re-read the tree or re-run the query instead of reusing an ID.";
77
80
 
78
- const APPLICATION_ID_DESCRIPTION = "Application ID to query. If not specified, uses the first connected app.";
81
+ const APPLICATION_ID_DESCRIPTION =
82
+ "Application ID to query. If not specified, uses the first connected app. The tool waits briefly for the " +
83
+ "requested app to register.";
84
+
85
+ const APP_TIMEOUT_DESCRIPTION = "Milliseconds to wait for the requested app to register (default: 10000).";
79
86
 
80
87
  const WIDGET_ID_DESCRIPTION =
81
88
  "Widget ID obtained from `gtkx_get_widget_tree`, `gtkx_query_widgets`, or `gtkx_get_widget_props`. " +
82
89
  "IDs are scoped to a single app. An ID stays valid for as long as its widget is mounted and stops " +
83
90
  "resolving once the widget is unmounted.";
84
91
 
85
- const applicationIdShape = { applicationId: z.string().optional().describe(APPLICATION_ID_DESCRIPTION) };
92
+ const applicationIdShape = {
93
+ applicationId: z.string().optional().describe(APPLICATION_ID_DESCRIPTION),
94
+ appTimeout: z.number().int().nonnegative().optional().describe(APP_TIMEOUT_DESCRIPTION),
95
+ };
86
96
 
87
97
  const widgetIdShape = {
88
98
  ...applicationIdShape,
@@ -148,7 +158,7 @@ const fireEventShape = {
148
158
  ...applicationIdShape,
149
159
  ...describeParams(fireEventParams.shape, {
150
160
  widgetId: WIDGET_ID_DESCRIPTION,
151
- signal: "GTK4 signal name to emit",
161
+ signal: "GObject signal name to emit",
152
162
  args: "Arguments to pass to the signal",
153
163
  }),
154
164
  };
@@ -220,11 +230,13 @@ const listAppsTool = (appRouter: AppRouter): Tool =>
220
230
  name: "gtkx_list_apps",
221
231
  title: "List apps",
222
232
  kind: "readOnly",
223
- description: "List all connected GTKX applications and their open windows.",
233
+ description:
234
+ "List connected GTKX applications and their open windows. " +
235
+ "Returns the current snapshot immediately unless waitForApps is true.",
224
236
  inputSchema: listAppsShape,
225
237
  handler: async ({ waitForApps, timeout }) => {
226
238
  if (waitForApps && !appRouter.hasConnectedApps()) {
227
- await appRouter.waitForApp(timeout);
239
+ await appRouter.waitForApp(undefined, timeout);
228
240
  }
229
241
 
230
242
  const apps = appRouter.getApps();
@@ -266,11 +278,12 @@ const screenshotTool = (appRouter: AppRouter): Tool =>
266
278
  "the PNG to `path` on the app's machine. You can't target widgets from a screenshot; use " +
267
279
  "`gtkx_get_widget_tree` to find widget IDs for interaction.",
268
280
  inputSchema: screenshotShape,
269
- handler: async ({ applicationId, returnImage, ...params }) => {
281
+ handler: async ({ applicationId, appTimeout, returnImage, ...params }) => {
270
282
  const result = await appRouter.sendToApp<{ data: string; mimeType: string; savedPath?: string }>(
271
283
  applicationId,
272
284
  "widget.screenshot",
273
285
  params,
286
+ appTimeout,
274
287
  );
275
288
 
276
289
  return screenshotResult(result, returnImage !== false);
@@ -296,8 +309,8 @@ const widgetPropsTool = (appRouter: AppRouter): Tool =>
296
309
  "a property the widget does not have fails; a value that cannot be marshalled carries a `note` " +
297
310
  "instead.",
298
311
  inputSchema: widgetPropsShape,
299
- handler: async ({ applicationId, ...params }) => {
300
- const result = await appRouter.sendToApp(applicationId, "widget.getProps", params);
312
+ handler: async ({ applicationId, appTimeout, ...params }) => {
313
+ const result = await appRouter.sendToApp(applicationId, "widget.getProps", params, appTimeout);
301
314
 
302
315
  return textContent(JSON.stringify(result, null, 2));
303
316
  },
@@ -315,11 +328,11 @@ function buildInspectionTools(appRouter: AppRouter): Tool[] {
315
328
  "types, roles, and properties. For large apps, pass `maxDepth` for a shallow overview and/or " +
316
329
  "`rootId` to render just one subtree instead of the whole (possibly truncated) tree.",
317
330
  inputSchema: treeShape,
318
- handler: async ({ applicationId, rootId, maxDepth }) => {
331
+ handler: async ({ applicationId, appTimeout, rootId, maxDepth }) => {
319
332
  const result = await appRouter.sendToApp<{ tree: string }>(applicationId, "widget.getTree", {
320
333
  rootId,
321
334
  maxDepth,
322
- });
335
+ }, appTimeout);
323
336
 
324
337
  return textContent(result.tree);
325
338
  },
@@ -334,8 +347,8 @@ function buildInspectionTools(appRouter: AppRouter): Tool[] {
334
347
  "children carries `hiddenChildren`, the count of its direct children left out. Read a " +
335
348
  "match's subtree with `gtkx_get_widget_props` or `gtkx_get_widget_tree`.",
336
349
  inputSchema: queryWidgetsShape,
337
- handler: async ({ applicationId, ...params }) => {
338
- const result = await appRouter.sendToApp(applicationId, "widget.query", params);
350
+ handler: async ({ applicationId, appTimeout, ...params }) => {
351
+ const result = await appRouter.sendToApp(applicationId, "widget.query", params, appTimeout);
339
352
 
340
353
  return textContent(JSON.stringify(result, null, 2));
341
354
  },
@@ -356,8 +369,8 @@ function buildInteractionTools(appRouter: AppRouter): Tool[] {
356
369
  "buttons, checkboxes, switches, list and grid rows, tree expanders, and column headers: a " +
357
370
  "row is selected, an expander toggles its row's expansion, and a header sorts its column.",
358
371
  inputSchema: widgetIdShape,
359
- handler: async ({ applicationId, ...params }) => {
360
- await appRouter.sendToApp(applicationId, "widget.click", params);
372
+ handler: async ({ applicationId, appTimeout, ...params }) => {
373
+ await appRouter.sendToApp(applicationId, "widget.click", params, appTimeout);
361
374
 
362
375
  return textContent("Clicked");
363
376
  },
@@ -368,8 +381,8 @@ function buildInteractionTools(appRouter: AppRouter): Tool[] {
368
381
  kind: "action",
369
382
  description: "Type text into an editable widget like Entry or TextView",
370
383
  inputSchema: typeShape,
371
- handler: async ({ applicationId, ...params }) => {
372
- await appRouter.sendToApp(applicationId, "widget.type", params);
384
+ handler: async ({ applicationId, appTimeout, ...params }) => {
385
+ await appRouter.sendToApp(applicationId, "widget.type", params, appTimeout);
373
386
 
374
387
  return textContent("Typed text");
375
388
  },
@@ -378,10 +391,10 @@ function buildInteractionTools(appRouter: AppRouter): Tool[] {
378
391
  name: "gtkx_fire_event",
379
392
  title: "Fire event",
380
393
  kind: "action",
381
- description: "Emit a GTK4 signal on a widget. Use this for custom interactions.",
394
+ description: "Emit a GObject signal on a widget. Use this for custom interactions.",
382
395
  inputSchema: fireEventShape,
383
- handler: async ({ applicationId, ...params }) => {
384
- const result = await appRouter.sendToApp(applicationId, "widget.fireEvent", params);
396
+ handler: async ({ applicationId, appTimeout, ...params }) => {
397
+ const result = await appRouter.sendToApp(applicationId, "widget.fireEvent", params, appTimeout);
385
398
 
386
399
  return textContent(JSON.stringify(result, null, 2));
387
400
  },
@@ -410,42 +423,97 @@ const registerTools = (
410
423
  }
411
424
  };
412
425
 
413
- async function stopServer(state: ServerLifecycle): Promise<void> {
414
- if (state.isStopped) {
415
- return;
426
+ const stoppedServerError = (): Error => new Error("createMcpServer: a stopped server cannot be started again");
427
+
428
+ const wasStopRequested = (state: ServerLifecycle): boolean => state.isStopRequested;
429
+
430
+ function stopServer(state: ServerLifecycle): Promise<void> {
431
+ if (state.shutdown !== null) {
432
+ return state.shutdown;
416
433
  }
417
434
 
418
- state.isStopped = true;
419
- await state.socketServer.stop();
420
- await state.mcpServer.close();
435
+ state.isStopRequested = true;
436
+ state.appRouter.dispose();
437
+ const startup = state.startup;
438
+ const shutdown = (async (): Promise<void> => {
439
+ if (startup !== null) {
440
+ await Promise.allSettled([startup]);
441
+ }
442
+
443
+ await state.socketServer.stop();
444
+ await state.mcpServer.close();
445
+ })();
446
+
447
+ state.shutdown = shutdown;
448
+
449
+ return shutdown;
421
450
  }
422
451
 
423
- async function startServer(state: ServerLifecycle): Promise<void> {
424
- if (state.isStopped) {
425
- throw new Error("createMcpServer: a stopped server cannot be started again");
452
+ function startServer(state: ServerLifecycle): Promise<void> {
453
+ if (wasStopRequested(state)) {
454
+ return Promise.reject(stoppedServerError());
426
455
  }
427
456
 
428
- await state.socketServer.start();
429
-
430
- if (state.isStarted) {
431
- return;
457
+ if (state.startup !== null) {
458
+ return state.startup;
432
459
  }
433
460
 
434
- state.isStarted = true;
435
- log.info(`socket server listening on ${state.socketPath}`);
436
- await connectStdio(state.mcpServer, () => stopServer(state));
461
+ const startup = (async (): Promise<void> => {
462
+ try {
463
+ await state.socketServer.start();
464
+
465
+ if (wasStopRequested(state)) {
466
+ return;
467
+ }
468
+
469
+ log.info(`socket server listening on ${state.socketPath}`);
470
+ await connectStdio(state.mcpServer, () => stopServer(state));
471
+ } catch (error) {
472
+ if (!wasStopRequested(state)) {
473
+ state.isStopRequested = true;
474
+ state.appRouter.dispose();
475
+ await state.socketServer.stop();
476
+ await state.mcpServer.close();
477
+ }
478
+
479
+ throw error;
480
+ }
481
+ })();
482
+
483
+ state.startup = startup;
484
+
485
+ return startup;
437
486
  }
438
487
 
439
- const createServerHandle = (socketServer: SocketServer, mcpServer: McpServer, socketPath: string): McpServerHandle => {
440
- const state: ServerLifecycle = { socketServer, mcpServer, socketPath, isStopped: false, isStarted: false };
488
+ const createServerHandle = (
489
+ socketServer: SocketServer,
490
+ mcpServer: McpServer,
491
+ appRouter: AppRouter,
492
+ socketPath: string,
493
+ ): McpServerHandle => {
494
+ const state: ServerLifecycle = {
495
+ socketServer,
496
+ mcpServer,
497
+ appRouter,
498
+ socketPath,
499
+ isStopRequested: false,
500
+ startup: null,
501
+ shutdown: null,
502
+ };
441
503
 
442
504
  return { start: () => startServer(state), stop: () => stopServer(state) };
443
505
  };
444
506
 
445
507
  const createMcpServer = (options: CreateMcpServerOptions): McpServerHandle => {
446
- const socketPath = options.socketPath ?? DEFAULT_SOCKET_PATH;
508
+ const socketAddress = resolveMcpSocketAddress(options.socketPath);
509
+ const socketPath = socketAddress.path;
510
+
511
+ if (socketAddress.fallbackDirectory !== null) {
512
+ log.warn(`MCP socket path exceeds the safe Unix byte budget; using private fallback ${socketPath}`);
513
+ }
514
+
447
515
  const registry = new ConnectionRegistry();
448
- const socketServer = new SocketServer(registry, socketPath);
516
+ const socketServer = new SocketServer(registry, socketAddress);
449
517
  const appRouter = new AppRouter(registry);
450
518
  registry.addEventListener("error", logSocketError);
451
519
 
@@ -463,7 +531,7 @@ const createMcpServer = (options: CreateMcpServerOptions): McpServerHandle => {
463
531
  registerTools(mcpServer, appRouter, referenceProvider, options.settings ?? DEFAULT_SETTINGS);
464
532
  registerReferenceResources(mcpServer, referenceProvider);
465
533
 
466
- return createServerHandle(socketServer, mcpServer, socketPath);
534
+ return createServerHandle(socketServer, mcpServer, appRouter, socketPath);
467
535
  };
468
536
 
469
537
  const configuredSettings = async (cwd: string): Promise<McpSettings> => {
@@ -0,0 +1,103 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync, mkdirSync, rmdirSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ type McpSocketAddress = {
7
+ path: string;
8
+ fallbackDirectory: string | null;
9
+ };
10
+
11
+ const MCP_SOCKET_PATH_ENV = "GTKX_MCP_SOCKET_PATH";
12
+ const SOCKET_NAME = "gtkx-mcp.sock";
13
+ const SOCKET_PATH_BYTE_LIMIT = 100;
14
+ const SHORT_SOCKET_ROOT = "/tmp";
15
+
16
+ const socketCandidate = (): string =>
17
+ process.env[MCP_SOCKET_PATH_ENV] ?? join(process.env.XDG_RUNTIME_DIR ?? tmpdir(), SOCKET_NAME);
18
+
19
+ const currentUserId = (): number => {
20
+ const getuid = process.getuid;
21
+
22
+ if (getuid === undefined) {
23
+ throw new Error("GTKX MCP Unix sockets require a platform with user IDs");
24
+ }
25
+
26
+ return getuid();
27
+ };
28
+
29
+ const verifyPrivateDirectory = (directory: string, userId: number): void => {
30
+ const entry = lstatSync(directory);
31
+
32
+ if (!entry.isDirectory() || entry.uid !== userId || (entry.mode & 0o777) !== 0o700) {
33
+ throw new Error(`GTKX MCP fallback socket directory is not private: ${directory}`);
34
+ }
35
+ };
36
+
37
+ const ensurePrivateDirectory = (directory: string, userId: number): void => {
38
+ try {
39
+ mkdirSync(directory, { mode: 0o700 });
40
+ } catch (error) {
41
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ verifyPrivateDirectory(directory, userId);
47
+ };
48
+
49
+ const fallbackAddress = (candidate: string): McpSocketAddress => {
50
+ const userId = currentUserId();
51
+ const digest = createHash("sha256").update(candidate).digest("hex").slice(0, 24);
52
+ const fallbackDirectory = join(SHORT_SOCKET_ROOT, `gtkx-mcp-${String(userId)}-${digest}`);
53
+
54
+ try {
55
+ verifyPrivateDirectory(fallbackDirectory, userId);
56
+ } catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
58
+ throw error;
59
+ }
60
+ }
61
+
62
+ return { path: join(fallbackDirectory, "socket"), fallbackDirectory };
63
+ };
64
+
65
+ const resolveMcpSocketAddress = (providedPath?: string): McpSocketAddress => {
66
+ const candidate = providedPath ?? socketCandidate();
67
+
68
+ return Buffer.byteLength(candidate) <= SOCKET_PATH_BYTE_LIMIT
69
+ ? { path: candidate, fallbackDirectory: null }
70
+ : fallbackAddress(candidate);
71
+ };
72
+
73
+ const resolveMcpSocketPath = (providedPath?: string): string => resolveMcpSocketAddress(providedPath).path;
74
+
75
+ const prepareMcpSocketAddress = (address: McpSocketAddress): void => {
76
+ if (address.fallbackDirectory !== null) {
77
+ ensurePrivateDirectory(address.fallbackDirectory, currentUserId());
78
+ }
79
+ };
80
+
81
+ const cleanupMcpSocketAddress = (address: McpSocketAddress): void => {
82
+ const directory = address.fallbackDirectory;
83
+
84
+ if (directory === null) {
85
+ return;
86
+ }
87
+
88
+ try {
89
+ verifyPrivateDirectory(directory, currentUserId());
90
+ rmdirSync(directory);
91
+ } catch {
92
+ return;
93
+ }
94
+ };
95
+
96
+ export {
97
+ cleanupMcpSocketAddress,
98
+ MCP_SOCKET_PATH_ENV,
99
+ prepareMcpSocketAddress,
100
+ resolveMcpSocketAddress,
101
+ resolveMcpSocketPath,
102
+ type McpSocketAddress,
103
+ };
@@ -3,11 +3,16 @@ import * as fs from "node:fs";
3
3
  import * as net from "node:net";
4
4
  import { basename, dirname, join, resolve as resolvePath } from "node:path";
5
5
  import type { ConnectionRegistry } from "./connection-registry.js";
6
- import { DEFAULT_SOCKET_PATH } from "./protocol/schemas.js";
6
+ import {
7
+ cleanupMcpSocketAddress,
8
+ type McpSocketAddress,
9
+ prepareMcpSocketAddress,
10
+ resolveMcpSocketAddress,
11
+ } from "./socket-path.js";
7
12
  import { connectionErrorEvent } from "./transport.js";
8
13
 
9
14
  type ProbeOutcome = { kind: "live" } | { kind: "unknown"; code: string } | { kind: "vacant" };
10
- type PathVerdict = ProbeOutcome | { kind: "directory" };
15
+ type PathVerdict = ProbeOutcome | { kind: "invalid" };
11
16
  type ClaimOutcome = "occupied" | "published";
12
17
 
13
18
  const PROBE_TIMEOUT_MS = 1000;
@@ -75,12 +80,6 @@ const acquireClaimLock = async (socketPath: string): Promise<net.Server | null>
75
80
  return lock;
76
81
  };
77
82
 
78
- const releaseClaimLock = async (lock: net.Server | null): Promise<void> => {
79
- if (lock) {
80
- await closeServer(lock);
81
- }
82
- };
83
-
84
83
  const withClaimLock = async <T>(socketPath: string, action: () => Promise<T> | T): Promise<T> => {
85
84
  const lock = await acquireClaimLock(socketPath);
86
85
 
@@ -159,10 +158,10 @@ const undecidedOwnerError = (socketPath: string, code: string): Error =>
159
158
  "Retry, or delete the file by hand once no server is running.",
160
159
  );
161
160
 
162
- const directoryPathError = (socketPath: string): Error =>
161
+ const invalidPathError = (socketPath: string): Error =>
163
162
  new Error(
164
- `The GTKX MCP socket path ${socketPath} is a directory, not a socket. ` +
165
- "Remove it, or point XDG_RUNTIME_DIR at a directory where GTKX can create its socket.",
163
+ `The GTKX MCP socket path ${socketPath} exists and is not a socket. ` +
164
+ "Move it, or point XDG_RUNTIME_DIR at a directory where GTKX can create its socket.",
166
165
  );
167
166
 
168
167
  const listenFailureError = (socketPath: string, code: string): Error =>
@@ -184,8 +183,8 @@ const clearStalePath = async (target: string): Promise<PathVerdict> => {
184
183
  return { kind: "vacant" };
185
184
  }
186
185
 
187
- if (entry.isDirectory()) {
188
- return { kind: "directory" };
186
+ if (!entry.isSocket()) {
187
+ return { kind: "invalid" };
189
188
  }
190
189
 
191
190
  const outcome = await probeUntilConclusive(target);
@@ -204,8 +203,8 @@ const requireVacantPath = async (socketPath: string): Promise<void> => {
204
203
  throw alreadyOwnedError(socketPath);
205
204
  }
206
205
 
207
- if (verdict.kind === "directory") {
208
- throw directoryPathError(socketPath);
206
+ if (verdict.kind === "invalid") {
207
+ throw invalidPathError(socketPath);
209
208
  }
210
209
 
211
210
  if (verdict.kind === "unknown") {
@@ -242,26 +241,18 @@ const publishSocket = async (privatePath: string, socketPath: string): Promise<n
242
241
  throw alreadyOwnedError(socketPath);
243
242
  };
244
243
 
245
- const releaseSocketPath = async (socketPath: string, inode: number): Promise<void> => {
246
- const lock = await acquireClaimLock(socketPath);
247
-
248
- try {
249
- removeEntry(socketPath, inode);
250
- } finally {
251
- await releaseClaimLock(lock);
252
- }
253
- };
254
-
255
244
  class SocketServer {
256
245
  private server: net.Server | null = null;
257
246
  private socketPath: string;
258
247
  private registry: ConnectionRegistry;
248
+ private address: McpSocketAddress;
259
249
  private boundInode: number | null = null;
260
250
  private startup: Promise<void> | null = null;
261
251
 
262
- constructor(registry: ConnectionRegistry, socketPath: string = DEFAULT_SOCKET_PATH) {
252
+ constructor(registry: ConnectionRegistry, address: McpSocketAddress = resolveMcpSocketAddress()) {
263
253
  this.registry = registry;
264
- this.socketPath = socketPath;
254
+ this.address = address;
255
+ this.socketPath = address.path;
265
256
  }
266
257
 
267
258
  private listen(privatePath: string): Promise<net.Server> {
@@ -294,21 +285,46 @@ class SocketServer {
294
285
  }
295
286
 
296
287
  private async bind(): Promise<void> {
288
+ prepareMcpSocketAddress(this.address);
297
289
  const privatePath = privatePathFor(this.socketPath);
298
290
  await clearStalePath(privatePath);
299
291
  const server = await this.listenPrivately(privatePath);
292
+ const privateInode = inodeFor(privatePath);
300
293
 
301
294
  try {
302
- this.boundInode = await publishSocket(privatePath, this.socketPath);
295
+ if (privateInode === null) {
296
+ throw listenFailureError(this.socketPath, "ENOENT");
297
+ }
298
+
299
+ const publishedInode = await publishSocket(privatePath, this.socketPath);
300
+
301
+ if (publishedInode !== privateInode) {
302
+ throw listenFailureError(this.socketPath, "ESTALE");
303
+ }
304
+
305
+ this.boundInode = privateInode;
303
306
  this.server = server;
304
307
  } catch (error) {
305
308
  await closeServer(server);
309
+
310
+ if (privateInode !== null) {
311
+ removeEntry(privatePath, privateInode);
312
+ removeEntry(this.socketPath, privateInode);
313
+ }
314
+
306
315
  throw error;
307
316
  }
308
317
  }
309
318
 
310
319
  private open(): Promise<void> {
311
- return withClaimLock(this.socketPath, () => this.bind());
320
+ return withClaimLock(this.socketPath, async () => {
321
+ try {
322
+ await this.bind();
323
+ } catch (error) {
324
+ cleanupMcpSocketAddress(this.address);
325
+ throw error;
326
+ }
327
+ });
312
328
  }
313
329
 
314
330
  private async settleStartup(): Promise<void> {
@@ -320,13 +336,23 @@ class SocketServer {
320
336
  }
321
337
  }
322
338
 
323
- private async release(): Promise<void> {
339
+ private async release(server: net.Server): Promise<void> {
324
340
  const inode = this.boundInode;
325
- this.boundInode = null;
326
341
 
327
- if (inode !== null) {
328
- await releaseSocketPath(this.socketPath, inode);
329
- }
342
+ await withClaimLock(this.socketPath, async () => {
343
+ this.registry.dispose();
344
+ await closeServer(server);
345
+
346
+ try {
347
+ if (inode !== null) {
348
+ removeEntry(this.socketPath, inode);
349
+ }
350
+ } finally {
351
+ cleanupMcpSocketAddress(this.address);
352
+ }
353
+ });
354
+
355
+ this.boundInode = null;
330
356
  }
331
357
 
332
358
  async start(): Promise<void> {
@@ -352,10 +378,8 @@ class SocketServer {
352
378
  return;
353
379
  }
354
380
 
381
+ await this.release(server);
355
382
  this.server = null;
356
- this.registry.dispose();
357
- await closeServer(server);
358
- await this.release();
359
383
  }
360
384
  }
361
385