@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/README.md +5 -5
- 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 +13 -35
- package/dist/reference.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +86 -39
- 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 +12 -8
- package/src/app-router.ts +77 -22
- package/src/internal.ts +4 -1
- package/src/protocol/schemas.ts +0 -9
- package/src/reference.ts +15 -42
- package/src/server.ts +107 -41
- package/src/socket-path.ts +103 -0
- package/src/socket-server.ts +61 -37
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", "
|
|
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
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
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
|
-
|
|
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
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 =
|
|
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,
|
|
@@ -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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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.
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
424
|
-
if (state
|
|
425
|
-
|
|
450
|
+
function startServer(state: ServerLifecycle): Promise<void> {
|
|
451
|
+
if (wasStopRequested(state)) {
|
|
452
|
+
return Promise.reject(stoppedServerError());
|
|
426
453
|
}
|
|
427
454
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
if (state.isStarted) {
|
|
431
|
-
return;
|
|
455
|
+
if (state.startup !== null) {
|
|
456
|
+
return state.startup;
|
|
432
457
|
}
|
|
433
458
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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 = (
|
|
440
|
-
|
|
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
|
|
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,
|
|
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
|
+
};
|
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
|
|