@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/README.md +47 -44
- package/dist/app-router.d.ts +6 -2
- package/dist/app-router.d.ts.map +1 -1
- package/dist/app-router.js +56 -19
- package/dist/app-router.js.map +1 -1
- package/dist/internal.d.ts +2 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +2 -1
- package/dist/internal.js.map +1 -1
- package/dist/protocol/schemas.d.ts +1 -2
- package/dist/protocol/schemas.d.ts.map +1 -1
- package/dist/protocol/schemas.js +1 -7
- package/dist/protocol/schemas.js.map +1 -1
- package/dist/reference.d.ts.map +1 -1
- package/dist/reference.js +25 -46
- package/dist/reference.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +92 -44
- package/dist/server.js.map +1 -1
- package/dist/socket-path.d.ts +11 -0
- package/dist/socket-path.d.ts.map +1 -0
- package/dist/socket-path.js +74 -0
- package/dist/socket-path.js.map +1 -0
- package/dist/socket-server.d.ts +3 -1
- package/dist/socket-server.d.ts.map +1 -1
- package/dist/socket-server.js +48 -32
- package/dist/socket-server.js.map +1 -1
- package/package.json +15 -9
- package/src/app-router.ts +77 -22
- package/src/internal.ts +4 -1
- package/src/protocol/schemas.ts +0 -9
- package/src/reference.ts +27 -53
- package/src/server.ts +114 -46
- package/src/socket-path.ts +103 -0
- package/src/socket-server.ts +61 -37
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
|
-
|
|
58
|
-
|
|
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
|
|
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
|
-
"
|
|
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 =
|
|
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 = {
|
|
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: "
|
|
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:
|
|
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
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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.
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
424
|
-
if (state
|
|
425
|
-
|
|
452
|
+
function startServer(state: ServerLifecycle): Promise<void> {
|
|
453
|
+
if (wasStopRequested(state)) {
|
|
454
|
+
return Promise.reject(stoppedServerError());
|
|
426
455
|
}
|
|
427
456
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
if (state.isStarted) {
|
|
431
|
-
return;
|
|
457
|
+
if (state.startup !== null) {
|
|
458
|
+
return state.startup;
|
|
432
459
|
}
|
|
433
460
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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 = (
|
|
440
|
-
|
|
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
|
|
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,
|
|
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
|
+
};
|
package/src/socket-server.ts
CHANGED
|
@@ -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 {
|
|
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: "
|
|
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
|
|
161
|
+
const invalidPathError = (socketPath: string): Error =>
|
|
163
162
|
new Error(
|
|
164
|
-
`The GTKX MCP socket path ${socketPath}
|
|
165
|
-
"
|
|
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.
|
|
188
|
-
return { kind: "
|
|
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 === "
|
|
208
|
-
throw
|
|
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,
|
|
252
|
+
constructor(registry: ConnectionRegistry, address: McpSocketAddress = resolveMcpSocketAddress()) {
|
|
263
253
|
this.registry = registry;
|
|
264
|
-
this.
|
|
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
|
-
|
|
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, () =>
|
|
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
|
-
|
|
328
|
-
|
|
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
|
|