@gtkx/mcp 2.0.0-beta.1 → 2.0.0-beta.2

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/app-router.ts CHANGED
@@ -16,6 +16,7 @@ import { type AppInfo, RegisterParamsSchema } from "./protocol/schemas.js";
16
16
 
17
17
  type AppRegisteredEvent = CustomEvent<AppInfo>;
18
18
  type AppUnregisteredEvent = CustomEvent<string>;
19
+ type PendingAppWait = { reject: (error: Error) => void };
19
20
 
20
21
  type RegisteredApp = {
21
22
  info: AppInfo;
@@ -24,6 +25,14 @@ type RegisteredApp = {
24
25
 
25
26
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
26
27
 
28
+ const routerStoppedError = (): Error => new Error("GTKX MCP server stopped while waiting for an application");
29
+
30
+ const appWaitTimeoutError = (timeout: number): Error =>
31
+ new Error(
32
+ `Timeout waiting for app registration after ${String(timeout)}ms. ` +
33
+ "Make sure your GTKX app is running with 'gtkx dev'.",
34
+ );
35
+
27
36
  function appRegisteredEvent(info: AppInfo): AppRegisteredEvent {
28
37
  return new CustomEvent("appRegistered", { detail: info });
29
38
  }
@@ -43,6 +52,10 @@ class AppRouter extends EventTarget {
43
52
 
44
53
  private connections: AppConnections;
45
54
 
55
+ private pendingAppWaits: Set<PendingAppWait> = new Set();
56
+
57
+ private isDisposed = false;
58
+
46
59
  constructor(connections: AppConnections, options: { requestTimeout?: number } = {}) {
47
60
  super();
48
61
  this.connections = connections;
@@ -69,6 +82,10 @@ class AppRouter extends EventTarget {
69
82
  }
70
83
 
71
84
  private handleRequest(connection: ProtocolConnection, request: JSONRPCRequest): Promise<Result> {
85
+ if (this.isDisposed) {
86
+ return Promise.reject(routerStoppedError());
87
+ }
88
+
72
89
  if (request.method === "app.register") {
73
90
  return Promise.resolve(this.handleRegister(connection, request));
74
91
  }
@@ -134,6 +151,40 @@ class AppRouter extends EventTarget {
134
151
  this.dispatchEvent(appUnregisteredEvent(applicationId));
135
152
  }
136
153
 
154
+ private async waitForRegistration(applicationId: string | undefined, timeout: number): Promise<AppInfo> {
155
+ const controller = new AbortController();
156
+ const { promise, reject, resolve } = Promise.withResolvers<AppInfo>();
157
+ const rejectWait = (error: Error): void => {
158
+ controller.abort();
159
+ reject(error);
160
+ };
161
+ const pendingWait: PendingAppWait = { reject: rejectWait };
162
+ const timeoutId = setTimeout(() => {
163
+ rejectWait(appWaitTimeoutError(timeout));
164
+ }, timeout);
165
+
166
+ this.addEventListener(
167
+ "appRegistered",
168
+ (event) => {
169
+ const info = (event as AppRegisteredEvent).detail;
170
+
171
+ if (applicationId === undefined || info.applicationId === applicationId) {
172
+ resolve(info);
173
+ }
174
+ },
175
+ { signal: controller.signal },
176
+ );
177
+ this.pendingAppWaits.add(pendingWait);
178
+
179
+ try {
180
+ return await promise;
181
+ } finally {
182
+ clearTimeout(timeoutId);
183
+ controller.abort();
184
+ this.pendingAppWaits.delete(pendingWait);
185
+ }
186
+ }
187
+
137
188
  getApps(): AppInfo[] {
138
189
  return this.apps.values().map((app) => app.info).toArray();
139
190
  }
@@ -152,36 +203,40 @@ class AppRouter extends EventTarget {
152
203
  return this.getDefaultApp()?.info.projectRoot;
153
204
  }
154
205
 
155
- waitForApp(timeout: number = AppRouter.defaultWaitTimeout): Promise<AppInfo> {
156
- const defaultApp = this.getDefaultApp();
206
+ dispose(): void {
207
+ if (this.isDisposed) {
208
+ return;
209
+ }
210
+
211
+ this.isDisposed = true;
212
+ const error = routerStoppedError();
157
213
 
158
- if (defaultApp) {
159
- return Promise.resolve(defaultApp.info);
214
+ for (const pendingWait of this.pendingAppWaits) {
215
+ pendingWait.reject(error);
160
216
  }
217
+ }
161
218
 
162
- return new Promise((resolve, reject) => {
163
- const timeoutId = setTimeout(() => {
164
- this.removeEventListener("appRegistered", onRegister);
219
+ waitForApp(applicationId?: string, timeout: number = AppRouter.defaultWaitTimeout): Promise<AppInfo> {
220
+ if (this.isDisposed) {
221
+ return Promise.reject(routerStoppedError());
222
+ }
165
223
 
166
- reject(
167
- new Error(
168
- `Timeout waiting for app registration after ${String(timeout)}ms. ` +
169
- "Make sure your GTKX app is running with 'gtkx dev'.",
170
- ),
171
- );
172
- }, timeout);
224
+ const app = applicationId === undefined ? this.getDefaultApp() : this.apps.get(applicationId);
173
225
 
174
- const onRegister = (event: Event): void => {
175
- clearTimeout(timeoutId);
176
- this.removeEventListener("appRegistered", onRegister);
177
- resolve((event as AppRegisteredEvent).detail);
178
- };
226
+ if (app) {
227
+ return Promise.resolve(app.info);
228
+ }
179
229
 
180
- this.addEventListener("appRegistered", onRegister);
181
- });
230
+ return this.waitForRegistration(applicationId, timeout);
182
231
  }
183
232
 
184
- async sendToApp<T>(applicationId: string | undefined, method: string, params?: RequestParams): Promise<T> {
233
+ async sendToApp<T>(
234
+ applicationId: string | undefined,
235
+ method: string,
236
+ params?: RequestParams,
237
+ waitTimeout?: number,
238
+ ): Promise<T> {
239
+ await this.waitForApp(applicationId, waitTimeout);
185
240
  const app = this.resolveTargetApp(applicationId);
186
241
 
187
242
  try {
package/src/internal.ts CHANGED
@@ -6,7 +6,6 @@ export {
6
6
  widgetNotFoundError,
7
7
  } from "./protocol/errors.js";
8
8
  export {
9
- DEFAULT_SOCKET_PATH,
10
9
  DEFAULT_SUBTREE_DEPTH,
11
10
  MAX_SUBTREE_WIDGETS,
12
11
  type ParamsSchema,
@@ -16,5 +15,9 @@ export {
16
15
  type ServerRequestParams,
17
16
  ServerRequestParamsSchemas,
18
17
  } from "./protocol/schemas.js";
18
+ export {
19
+ MCP_SOCKET_PATH_ENV,
20
+ resolveMcpSocketPath,
21
+ } from "./socket-path.js";
19
22
  export { ProtocolConnection } from "./transport.js";
20
23
  export type { JSONRPCRequest, Result } from "@modelcontextprotocol/sdk/types.js";
@@ -1,5 +1,3 @@
1
- import { tmpdir } from "node:os";
2
- import { join } from "node:path";
3
1
  import { z } from "zod";
4
2
 
5
3
  type SerializedWidget = {
@@ -130,12 +128,6 @@ const ServerRequestParamsSchemas: {
130
128
  "widget.screenshot": screenshotParams,
131
129
  };
132
130
 
133
- const DEFAULT_SOCKET_PATH: string = join(getRuntimeDir(), "gtkx-mcp.sock");
134
-
135
- function getRuntimeDir(): string {
136
- return process.env.XDG_RUNTIME_DIR ?? tmpdir();
137
- }
138
-
139
131
  export {
140
132
  DEFAULT_SUBTREE_DEPTH,
141
133
  MAX_SUBTREE_WIDGETS,
@@ -148,7 +140,6 @@ export {
148
140
  fireEventParams,
149
141
  screenshotParams,
150
142
  ServerRequestParamsSchemas,
151
- DEFAULT_SOCKET_PATH,
152
143
  type SerializedWidget,
153
144
  type SerializedProperty,
154
145
  type AppInfo,
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
76
  "GTK in general; prefer them over recalled GTK knowledge, which is usually C, PyGObject or GJS and " +
74
77
  "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,
@@ -224,7 +234,7 @@ const listAppsTool = (appRouter: AppRouter): Tool =>
224
234
  inputSchema: listAppsShape,
225
235
  handler: async ({ waitForApps, timeout }) => {
226
236
  if (waitForApps && !appRouter.hasConnectedApps()) {
227
- await appRouter.waitForApp(timeout);
237
+ await appRouter.waitForApp(undefined, timeout);
228
238
  }
229
239
 
230
240
  const apps = appRouter.getApps();
@@ -266,11 +276,12 @@ const screenshotTool = (appRouter: AppRouter): Tool =>
266
276
  "the PNG to `path` on the app's machine. You can't target widgets from a screenshot; use " +
267
277
  "`gtkx_get_widget_tree` to find widget IDs for interaction.",
268
278
  inputSchema: screenshotShape,
269
- handler: async ({ applicationId, returnImage, ...params }) => {
279
+ handler: async ({ applicationId, appTimeout, returnImage, ...params }) => {
270
280
  const result = await appRouter.sendToApp<{ data: string; mimeType: string; savedPath?: string }>(
271
281
  applicationId,
272
282
  "widget.screenshot",
273
283
  params,
284
+ appTimeout,
274
285
  );
275
286
 
276
287
  return screenshotResult(result, returnImage !== false);
@@ -296,8 +307,8 @@ const widgetPropsTool = (appRouter: AppRouter): Tool =>
296
307
  "a property the widget does not have fails; a value that cannot be marshalled carries a `note` " +
297
308
  "instead.",
298
309
  inputSchema: widgetPropsShape,
299
- handler: async ({ applicationId, ...params }) => {
300
- const result = await appRouter.sendToApp(applicationId, "widget.getProps", params);
310
+ handler: async ({ applicationId, appTimeout, ...params }) => {
311
+ const result = await appRouter.sendToApp(applicationId, "widget.getProps", params, appTimeout);
301
312
 
302
313
  return textContent(JSON.stringify(result, null, 2));
303
314
  },
@@ -315,11 +326,11 @@ function buildInspectionTools(appRouter: AppRouter): Tool[] {
315
326
  "types, roles, and properties. For large apps, pass `maxDepth` for a shallow overview and/or " +
316
327
  "`rootId` to render just one subtree instead of the whole (possibly truncated) tree.",
317
328
  inputSchema: treeShape,
318
- handler: async ({ applicationId, rootId, maxDepth }) => {
329
+ handler: async ({ applicationId, appTimeout, rootId, maxDepth }) => {
319
330
  const result = await appRouter.sendToApp<{ tree: string }>(applicationId, "widget.getTree", {
320
331
  rootId,
321
332
  maxDepth,
322
- });
333
+ }, appTimeout);
323
334
 
324
335
  return textContent(result.tree);
325
336
  },
@@ -334,8 +345,8 @@ function buildInspectionTools(appRouter: AppRouter): Tool[] {
334
345
  "children carries `hiddenChildren`, the count of its direct children left out. Read a " +
335
346
  "match's subtree with `gtkx_get_widget_props` or `gtkx_get_widget_tree`.",
336
347
  inputSchema: queryWidgetsShape,
337
- handler: async ({ applicationId, ...params }) => {
338
- const result = await appRouter.sendToApp(applicationId, "widget.query", params);
348
+ handler: async ({ applicationId, appTimeout, ...params }) => {
349
+ const result = await appRouter.sendToApp(applicationId, "widget.query", params, appTimeout);
339
350
 
340
351
  return textContent(JSON.stringify(result, null, 2));
341
352
  },
@@ -356,8 +367,8 @@ function buildInteractionTools(appRouter: AppRouter): Tool[] {
356
367
  "buttons, checkboxes, switches, list and grid rows, tree expanders, and column headers: a " +
357
368
  "row is selected, an expander toggles its row's expansion, and a header sorts its column.",
358
369
  inputSchema: widgetIdShape,
359
- handler: async ({ applicationId, ...params }) => {
360
- await appRouter.sendToApp(applicationId, "widget.click", params);
370
+ handler: async ({ applicationId, appTimeout, ...params }) => {
371
+ await appRouter.sendToApp(applicationId, "widget.click", params, appTimeout);
361
372
 
362
373
  return textContent("Clicked");
363
374
  },
@@ -368,8 +379,8 @@ function buildInteractionTools(appRouter: AppRouter): Tool[] {
368
379
  kind: "action",
369
380
  description: "Type text into an editable widget like Entry or TextView",
370
381
  inputSchema: typeShape,
371
- handler: async ({ applicationId, ...params }) => {
372
- await appRouter.sendToApp(applicationId, "widget.type", params);
382
+ handler: async ({ applicationId, appTimeout, ...params }) => {
383
+ await appRouter.sendToApp(applicationId, "widget.type", params, appTimeout);
373
384
 
374
385
  return textContent("Typed text");
375
386
  },
@@ -380,8 +391,8 @@ function buildInteractionTools(appRouter: AppRouter): Tool[] {
380
391
  kind: "action",
381
392
  description: "Emit a GTK4 signal on a widget. Use this for custom interactions.",
382
393
  inputSchema: fireEventShape,
383
- handler: async ({ applicationId, ...params }) => {
384
- const result = await appRouter.sendToApp(applicationId, "widget.fireEvent", params);
394
+ handler: async ({ applicationId, appTimeout, ...params }) => {
395
+ const result = await appRouter.sendToApp(applicationId, "widget.fireEvent", params, appTimeout);
385
396
 
386
397
  return textContent(JSON.stringify(result, null, 2));
387
398
  },
@@ -410,42 +421,97 @@ const registerTools = (
410
421
  }
411
422
  };
412
423
 
413
- async function stopServer(state: ServerLifecycle): Promise<void> {
414
- if (state.isStopped) {
415
- return;
424
+ const stoppedServerError = (): Error => new Error("createMcpServer: a stopped server cannot be started again");
425
+
426
+ const wasStopRequested = (state: ServerLifecycle): boolean => state.isStopRequested;
427
+
428
+ function stopServer(state: ServerLifecycle): Promise<void> {
429
+ if (state.shutdown !== null) {
430
+ return state.shutdown;
416
431
  }
417
432
 
418
- state.isStopped = true;
419
- await state.socketServer.stop();
420
- await state.mcpServer.close();
433
+ state.isStopRequested = true;
434
+ state.appRouter.dispose();
435
+ const startup = state.startup;
436
+ const shutdown = (async (): Promise<void> => {
437
+ if (startup !== null) {
438
+ await Promise.allSettled([startup]);
439
+ }
440
+
441
+ await state.socketServer.stop();
442
+ await state.mcpServer.close();
443
+ })();
444
+
445
+ state.shutdown = shutdown;
446
+
447
+ return shutdown;
421
448
  }
422
449
 
423
- async function startServer(state: ServerLifecycle): Promise<void> {
424
- if (state.isStopped) {
425
- throw new Error("createMcpServer: a stopped server cannot be started again");
450
+ function startServer(state: ServerLifecycle): Promise<void> {
451
+ if (wasStopRequested(state)) {
452
+ return Promise.reject(stoppedServerError());
426
453
  }
427
454
 
428
- await state.socketServer.start();
429
-
430
- if (state.isStarted) {
431
- return;
455
+ if (state.startup !== null) {
456
+ return state.startup;
432
457
  }
433
458
 
434
- state.isStarted = true;
435
- log.info(`socket server listening on ${state.socketPath}`);
436
- await connectStdio(state.mcpServer, () => stopServer(state));
459
+ const startup = (async (): Promise<void> => {
460
+ try {
461
+ await state.socketServer.start();
462
+
463
+ if (wasStopRequested(state)) {
464
+ return;
465
+ }
466
+
467
+ log.info(`socket server listening on ${state.socketPath}`);
468
+ await connectStdio(state.mcpServer, () => stopServer(state));
469
+ } catch (error) {
470
+ if (!wasStopRequested(state)) {
471
+ state.isStopRequested = true;
472
+ state.appRouter.dispose();
473
+ await state.socketServer.stop();
474
+ await state.mcpServer.close();
475
+ }
476
+
477
+ throw error;
478
+ }
479
+ })();
480
+
481
+ state.startup = startup;
482
+
483
+ return startup;
437
484
  }
438
485
 
439
- const createServerHandle = (socketServer: SocketServer, mcpServer: McpServer, socketPath: string): McpServerHandle => {
440
- const state: ServerLifecycle = { socketServer, mcpServer, socketPath, isStopped: false, isStarted: false };
486
+ const createServerHandle = (
487
+ socketServer: SocketServer,
488
+ mcpServer: McpServer,
489
+ appRouter: AppRouter,
490
+ socketPath: string,
491
+ ): McpServerHandle => {
492
+ const state: ServerLifecycle = {
493
+ socketServer,
494
+ mcpServer,
495
+ appRouter,
496
+ socketPath,
497
+ isStopRequested: false,
498
+ startup: null,
499
+ shutdown: null,
500
+ };
441
501
 
442
502
  return { start: () => startServer(state), stop: () => stopServer(state) };
443
503
  };
444
504
 
445
505
  const createMcpServer = (options: CreateMcpServerOptions): McpServerHandle => {
446
- const socketPath = options.socketPath ?? DEFAULT_SOCKET_PATH;
506
+ const socketAddress = resolveMcpSocketAddress(options.socketPath);
507
+ const socketPath = socketAddress.path;
508
+
509
+ if (socketAddress.fallbackDirectory !== null) {
510
+ log.warn(`MCP socket path exceeds the safe Unix byte budget; using private fallback ${socketPath}`);
511
+ }
512
+
447
513
  const registry = new ConnectionRegistry();
448
- const socketServer = new SocketServer(registry, socketPath);
514
+ const socketServer = new SocketServer(registry, socketAddress);
449
515
  const appRouter = new AppRouter(registry);
450
516
  registry.addEventListener("error", logSocketError);
451
517
 
@@ -463,7 +529,7 @@ const createMcpServer = (options: CreateMcpServerOptions): McpServerHandle => {
463
529
  registerTools(mcpServer, appRouter, referenceProvider, options.settings ?? DEFAULT_SETTINGS);
464
530
  registerReferenceResources(mcpServer, referenceProvider);
465
531
 
466
- return createServerHandle(socketServer, mcpServer, socketPath);
532
+ return createServerHandle(socketServer, mcpServer, appRouter, socketPath);
467
533
  };
468
534
 
469
535
  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
+ };