@gtkx/mcp 1.6.0 → 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/reference.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { type ApiReference, type ApiSymbol, loadApiReference, resolveGirPath, resolveLibraries } from "@gtkx/codegen";
2
2
  import { loadConfig } from "@gtkx/config";
3
- import { resolveFuture } from "@gtkx/config/internal";
4
3
  import { type McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
5
4
  import { type CallToolResult, ErrorCode, McpError, type ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
6
5
  import { existsSync, statSync } from "node:fs";
@@ -59,7 +58,7 @@ type ResourceServer = Pick<McpServer, "registerResource">;
59
58
 
60
59
  const FRESHNESS_INTERVAL_MS = 2000;
61
60
  const FAILURE_RETRY_MS = 5000;
62
- const CONFIG_EXTENSIONS = ["ts", "mts", "cts", "js", "mjs", "cjs", "json"];
61
+ const CONFIG_EXTENSIONS = ["ts", "mts", "js", "mjs", "json"];
63
62
 
64
63
  const PROJECT_SOURCE_LABELS: Record<ProjectSource, string> = {
65
64
  argument: "requested with `projectRoot`",
@@ -193,17 +192,11 @@ const loadReference = async (requestedRoot: string): Promise<LoadedReference> =>
193
192
  );
194
193
  }
195
194
 
196
- const future = resolveFuture(config.future);
197
- const libraries = resolveLibraries(config.libraries, girPath, future.isAdwaitaDefault);
195
+ const libraries = resolveLibraries(config.libraries);
198
196
 
199
197
  const reference = loadApiReference({
200
198
  libraries,
201
199
  girPath,
202
- isByteArrayTyped: future.isByteArrayTyped,
203
- isValueUnwrapped: future.isValueUnwrapped,
204
- isFinishTrimmed: future.isFinishTrimmed,
205
- isInoutInPlace: future.isInoutInPlace,
206
- isTreeShaken: future.isTreeShaken,
207
200
  });
208
201
 
209
202
  const watched = [watchFile(resolve(root, configFile)), ...reference.girFiles.map((file) => watchFile(file))];
@@ -211,17 +204,13 @@ const loadReference = async (requestedRoot: string): Promise<LoadedReference> =>
211
204
  return { reference, root, watched };
212
205
  };
213
206
 
214
- const markFailed = async (entry: CacheEntry): Promise<void> => {
215
- try {
216
- await entry.pending;
217
- } catch {
218
- entry.failedAt = Date.now();
219
- }
220
- };
221
-
222
207
  const startLoad = (cache: ReferenceCache, root: string): CacheEntry => {
223
208
  const entry: CacheEntry = { pending: loadReference(root), verifiedAt: Date.now(), failedAt: undefined };
224
- void markFailed(entry);
209
+
210
+ void entry.pending.catch(() => {
211
+ entry.failedAt = Date.now();
212
+ });
213
+
225
214
  cache.set(root, entry);
226
215
 
227
216
  return entry;
@@ -259,11 +248,9 @@ const currentReference = async (cache: ReferenceCache, root: string): Promise<Lo
259
248
  return revalidate(cache, root, entry).pending;
260
249
  };
261
250
 
262
- const defaultWorkingDirectory = (): string => process.cwd();
263
-
264
251
  const createReferenceProvider = (options: ReferenceProviderOptions): ReferenceProvider => {
265
252
  const cache: ReferenceCache = new Map();
266
- const getWorkingDirectory = options.getWorkingDirectory ?? defaultWorkingDirectory;
253
+ const getWorkingDirectory = options.getWorkingDirectory ?? (() => process.cwd());
267
254
 
268
255
  const resolve = (projectRoot?: string): ResolvedProject =>
269
256
  resolveProject(getWorkingDirectory(), options.getAppRoot, projectRoot);
@@ -308,23 +295,12 @@ const scopedResult = async (
308
295
  const formatCandidates = (candidates: ApiSymbol[]): string =>
309
296
  candidates.map((candidate) => `- ${candidate.namespace}.${candidate.name} (${candidate.kind})`).join("\n");
310
297
 
311
- const buildSearchOptions = (args: ToolArgs<typeof searchApiShape>): SearchOptions => {
312
- const options: SearchOptions = { query: args.query };
313
-
314
- if (args.namespace !== undefined) {
315
- options.namespace = args.namespace;
316
- }
317
-
318
- if (args.kind !== undefined) {
319
- options.kinds = [args.kind];
320
- }
321
-
322
- if (args.limit !== undefined) {
323
- options.limit = args.limit;
324
- }
325
-
326
- return options;
327
- };
298
+ const buildSearchOptions = (args: ToolArgs<typeof searchApiShape>): SearchOptions => ({
299
+ query: args.query,
300
+ ...(args.namespace !== undefined && { namespace: args.namespace }),
301
+ ...(args.kind !== undefined && { kinds: [args.kind] }),
302
+ ...(args.limit !== undefined && { limit: args.limit }),
303
+ });
328
304
 
329
305
  const listApiResult = (reference: ReferenceApi, namespace: string | undefined): CallToolResult => {
330
306
  if (namespace === undefined) {
@@ -431,9 +407,6 @@ const withLoadFallback = async <T>(load: () => Promise<T>, fallback: T): Promise
431
407
  }
432
408
  };
433
409
 
434
- const namesStartingWith = (names: string[], value: string): string[] =>
435
- names.filter((name) => name.toLowerCase().startsWith(value.toLowerCase()));
436
-
437
410
  const completeNames = (
438
411
  provider: ReferenceProvider,
439
412
  value: string,
@@ -442,7 +415,7 @@ const completeNames = (
442
415
  withLoadFallback(async () => {
443
416
  const { reference } = await provider.get();
444
417
 
445
- return namesStartingWith(collect(reference), value);
418
+ return collect(reference).filter((name) => name.toLowerCase().startsWith(value.toLowerCase()));
446
419
  }, []);
447
420
 
448
421
  const namespaceCompleter =
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
+ };
@@ -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