@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/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/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 +5 -5
- package/src/app-router.ts +77 -22
- package/src/internal.ts +4 -1
- package/src/protocol/schemas.ts +0 -9
- package/src/server.ts +107 -41
- package/src/socket-path.ts +103 -0
- package/src/socket-server.ts +61 -37
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
|
-
|
|
156
|
-
|
|
206
|
+
dispose(): void {
|
|
207
|
+
if (this.isDisposed) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
this.isDisposed = true;
|
|
212
|
+
const error = routerStoppedError();
|
|
157
213
|
|
|
158
|
-
|
|
159
|
-
|
|
214
|
+
for (const pendingWait of this.pendingAppWaits) {
|
|
215
|
+
pendingWait.reject(error);
|
|
160
216
|
}
|
|
217
|
+
}
|
|
161
218
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
219
|
+
waitForApp(applicationId?: string, timeout: number = AppRouter.defaultWaitTimeout): Promise<AppInfo> {
|
|
220
|
+
if (this.isDisposed) {
|
|
221
|
+
return Promise.reject(routerStoppedError());
|
|
222
|
+
}
|
|
165
223
|
|
|
166
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
resolve((event as AppRegisteredEvent).detail);
|
|
178
|
-
};
|
|
226
|
+
if (app) {
|
|
227
|
+
return Promise.resolve(app.info);
|
|
228
|
+
}
|
|
179
229
|
|
|
180
|
-
|
|
181
|
-
});
|
|
230
|
+
return this.waitForRegistration(applicationId, timeout);
|
|
182
231
|
}
|
|
183
232
|
|
|
184
|
-
async sendToApp<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";
|
package/src/protocol/schemas.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|