@gtkx/mcp 2.0.0-beta.1 → 2.0.0-beta.11
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 +6 -169
- package/dist/app-router.d.ts +7 -7
- package/dist/app-router.d.ts.map +1 -1
- package/dist/app-router.js +63 -32
- package/dist/app-router.js.map +1 -1
- package/dist/connection-registry.d.ts +0 -1
- package/dist/connection-registry.d.ts.map +1 -1
- package/dist/connection-registry.js +0 -3
- package/dist/connection-registry.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 +2 -7
- 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 +32 -18
- package/dist/reference.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +110 -62
- 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/dist/tool-filter.d.ts.map +1 -1
- package/dist/tool-filter.js +1 -3
- package/dist/tool-filter.js.map +1 -1
- package/package.json +22 -8
- package/src/app-router.ts +84 -39
- package/src/connection-registry.ts +0 -3
- package/src/internal.ts +4 -1
- package/src/protocol/schemas.ts +1 -14
- package/src/reference.ts +48 -19
- package/src/server.ts +133 -64
- package/src/socket-path.ts +103 -0
- package/src/socket-server.ts +61 -37
- package/src/tool-filter.ts +1 -4
package/src/reference.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type ApiReference,
|
|
3
|
+
type ApiSymbol,
|
|
4
|
+
loadApiReference,
|
|
5
|
+
mergeOmittedProps,
|
|
6
|
+
resolveGirPath,
|
|
7
|
+
resolveLibraries,
|
|
8
|
+
} from "@gtkx/codegen";
|
|
9
|
+
import { readBuiltinElementsForDocs } from "@gtkx/codegen/internal";
|
|
2
10
|
import { loadConfig } from "@gtkx/config";
|
|
11
|
+
import {
|
|
12
|
+
CONFIG_EXTENSIONS,
|
|
13
|
+
configDependenciesFor,
|
|
14
|
+
resolveAcceptedChildTypes,
|
|
15
|
+
resolveElementProps,
|
|
16
|
+
resolveOmittedProps,
|
|
17
|
+
} from "@gtkx/config/internal";
|
|
3
18
|
import { type McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
19
|
import { type CallToolResult, ErrorCode, McpError, type ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
|
|
5
20
|
import { existsSync, statSync } from "node:fs";
|
|
@@ -58,7 +73,7 @@ type ResourceServer = Pick<McpServer, "registerResource">;
|
|
|
58
73
|
|
|
59
74
|
const FRESHNESS_INTERVAL_MS = 2000;
|
|
60
75
|
const FAILURE_RETRY_MS = 5000;
|
|
61
|
-
const
|
|
76
|
+
const CONFIG_LOCATIONS = ["gtkx.config", ".config/gtkx", ".config/gtkx.config"];
|
|
62
77
|
|
|
63
78
|
const PROJECT_SOURCE_LABELS: Record<ProjectSource, string> = {
|
|
64
79
|
argument: "requested with `projectRoot`",
|
|
@@ -79,8 +94,8 @@ const SYMBOL_KIND = z.enum([
|
|
|
79
94
|
]);
|
|
80
95
|
|
|
81
96
|
const SYMBOL_DESCRIPTION =
|
|
82
|
-
"Qualified symbol name (`
|
|
83
|
-
"or bare symbol name when unambiguous (`
|
|
97
|
+
"Qualified symbol name (`Adw.Toast`, `Gtk.Orientation`, `GLib.idleAdd`), JSX element name (`AdwToast`), " +
|
|
98
|
+
"or bare symbol name when unambiguous (`Toast`).";
|
|
84
99
|
|
|
85
100
|
const PROJECT_ROOT_DESCRIPTION =
|
|
86
101
|
"Directory of the GTKX project whose bindings to document, absolute or relative to the working directory. " +
|
|
@@ -96,13 +111,13 @@ const listApiShape = {
|
|
|
96
111
|
namespace: z
|
|
97
112
|
.string()
|
|
98
113
|
.optional()
|
|
99
|
-
.describe("Namespace to list (e.g. `
|
|
114
|
+
.describe("Namespace to list (e.g. `Adw`, `Gtk`, `Gio`). Omit for an overview of all namespaces."),
|
|
100
115
|
};
|
|
101
116
|
|
|
102
117
|
const searchApiShape = {
|
|
103
118
|
...projectRootShape,
|
|
104
119
|
query: z.string().describe("Case-insensitive substring of a symbol name, e.g. `headerbar` or `orientation`."),
|
|
105
|
-
namespace: z.string().optional().describe("Restrict matches to one namespace (e.g. `
|
|
120
|
+
namespace: z.string().optional().describe("Restrict matches to one namespace (e.g. `Adw`)."),
|
|
106
121
|
kind: SYMBOL_KIND.optional().describe("Restrict matches to one symbol kind."),
|
|
107
122
|
limit: z.number().int().min(1).optional().describe("Maximum number of results (default: 20)."),
|
|
108
123
|
};
|
|
@@ -128,10 +143,13 @@ const isFresh = (loaded: LoadedReference): boolean =>
|
|
|
128
143
|
const current = watchFile(file.path);
|
|
129
144
|
|
|
130
145
|
return current.mtimeMs === file.mtimeMs && current.size === file.size;
|
|
131
|
-
});
|
|
146
|
+
}) && loaded.reference.hasFreshInputs();
|
|
147
|
+
|
|
148
|
+
const configCandidatePaths = (directory: string): string[] =>
|
|
149
|
+
CONFIG_LOCATIONS.flatMap((location) =>
|
|
150
|
+
CONFIG_EXTENSIONS.map((extension) => join(directory, `${location}${extension}`)));
|
|
132
151
|
|
|
133
|
-
const hasConfigFile = (directory: string): boolean =>
|
|
134
|
-
CONFIG_EXTENSIONS.some((extension) => existsSync(join(directory, `gtkx.config.${extension}`)));
|
|
152
|
+
const hasConfigFile = (directory: string): boolean => configCandidatePaths(directory).some((path) => existsSync(path));
|
|
135
153
|
|
|
136
154
|
const findProjectRoot = (start: string): string | undefined => {
|
|
137
155
|
const current = resolve(start);
|
|
@@ -172,7 +190,8 @@ const resolveProject = (
|
|
|
172
190
|
};
|
|
173
191
|
|
|
174
192
|
const loadReference = async (requestedRoot: string): Promise<LoadedReference> => {
|
|
175
|
-
const
|
|
193
|
+
const loaded = await loadConfig(requestedRoot);
|
|
194
|
+
const { config, root } = loaded;
|
|
176
195
|
|
|
177
196
|
if (config.codegen === false) {
|
|
178
197
|
throw new Error(
|
|
@@ -182,7 +201,7 @@ const loadReference = async (requestedRoot: string): Promise<LoadedReference> =>
|
|
|
182
201
|
);
|
|
183
202
|
}
|
|
184
203
|
|
|
185
|
-
const girPath = resolveGirPath(config.girPath);
|
|
204
|
+
const girPath = resolveGirPath(config.girPath, root);
|
|
186
205
|
|
|
187
206
|
if (girPath.length === 0) {
|
|
188
207
|
throw new Error(
|
|
@@ -193,13 +212,22 @@ const loadReference = async (requestedRoot: string): Promise<LoadedReference> =>
|
|
|
193
212
|
}
|
|
194
213
|
|
|
195
214
|
const libraries = resolveLibraries(config.libraries);
|
|
215
|
+
const builtin = await readBuiltinElementsForDocs();
|
|
196
216
|
|
|
197
217
|
const reference = loadApiReference({
|
|
198
218
|
libraries,
|
|
199
219
|
girPath,
|
|
220
|
+
resolveFrom: root,
|
|
221
|
+
props: { ...builtin.props, ...resolveElementProps(config.elements) },
|
|
222
|
+
omittedProps: mergeOmittedProps(builtin.omittedProps, resolveOmittedProps(config.elements)),
|
|
223
|
+
acceptedChildTypes: { ...builtin.acceptedChildTypes, ...resolveAcceptedChildTypes(config.elements) },
|
|
200
224
|
});
|
|
201
225
|
|
|
202
|
-
const watched = [
|
|
226
|
+
const watched = [...new Set([
|
|
227
|
+
...configDependenciesFor(loaded),
|
|
228
|
+
...configCandidatePaths(root),
|
|
229
|
+
...reference.inputFiles,
|
|
230
|
+
])].map((file) => watchFile(file));
|
|
203
231
|
|
|
204
232
|
return { reference, root, watched };
|
|
205
233
|
};
|
|
@@ -354,8 +382,9 @@ const listApiTool = (provider: ReferenceProvider): Tool =>
|
|
|
354
382
|
title: "List API reference",
|
|
355
383
|
kind: "readOnly",
|
|
356
384
|
description:
|
|
357
|
-
"List the project's generated
|
|
358
|
-
"
|
|
385
|
+
"List the project's generated bindings API (`@gtkx/gi` and `@gtkx/jsx`), including Adwaita, " +
|
|
386
|
+
"GTK4, and its declared GIR libraries. Without a namespace, returns every namespace with symbol counts; " +
|
|
387
|
+
"with a namespace, lists all of its symbols grouped by kind.",
|
|
359
388
|
inputSchema: listApiShape,
|
|
360
389
|
handler: ({ namespace, projectRoot }) =>
|
|
361
390
|
scopedResult(provider, projectRoot, (reference) => listApiResult(reference, namespace)),
|
|
@@ -367,7 +396,7 @@ const searchApiTool = (provider: ReferenceProvider): Tool =>
|
|
|
367
396
|
title: "Search API reference",
|
|
368
397
|
kind: "readOnly",
|
|
369
398
|
description:
|
|
370
|
-
"Search the project's generated
|
|
399
|
+
"Search the project's generated bindings API by symbol name. Returns matching symbols with " +
|
|
371
400
|
"their namespace, kind, and a one-line summary; fetch full pages with `gtkx_get_api_docs`.",
|
|
372
401
|
inputSchema: searchApiShape,
|
|
373
402
|
handler: (args) => scopedResult(provider, args.projectRoot, (reference) => searchApiResult(reference, args)),
|
|
@@ -379,7 +408,7 @@ const getApiDocsTool = (provider: ReferenceProvider): Tool =>
|
|
|
379
408
|
title: "Get API docs",
|
|
380
409
|
kind: "readOnly",
|
|
381
410
|
description:
|
|
382
|
-
"Get the full reference page for one symbol of the project's generated
|
|
411
|
+
"Get the full reference page for one symbol of the project's generated bindings: JSX elements " +
|
|
383
412
|
"(props, signals, methods) or `@gtkx/gi` classes, interfaces, records, enums, callbacks, aliases, " +
|
|
384
413
|
"functions, and constants.",
|
|
385
414
|
inputSchema: apiDocsShape,
|
|
@@ -461,7 +490,7 @@ const registerIndexResource = (server: ResourceServer, provider: ReferenceProvid
|
|
|
461
490
|
"gtkx://reference/index",
|
|
462
491
|
{
|
|
463
492
|
title: "GTKX API reference index",
|
|
464
|
-
description: "Namespaces of the project's generated
|
|
493
|
+
description: "Namespaces of the project's generated bindings, with symbol and JSX element counts.",
|
|
465
494
|
mimeType: "text/markdown",
|
|
466
495
|
},
|
|
467
496
|
async (uri) => {
|
|
@@ -497,7 +526,7 @@ const registerNamespaceResource = (server: ResourceServer, provider: ReferencePr
|
|
|
497
526
|
}),
|
|
498
527
|
{
|
|
499
528
|
title: "GTKX namespace reference",
|
|
500
|
-
description: "All symbols of one namespace of the project's generated
|
|
529
|
+
description: "All symbols of one namespace of the project's generated bindings, grouped by kind.",
|
|
501
530
|
mimeType: "text/markdown",
|
|
502
531
|
},
|
|
503
532
|
async (uri, variables) => {
|
|
@@ -528,7 +557,7 @@ const registerSymbolResource = (server: ResourceServer, provider: ReferenceProvi
|
|
|
528
557
|
{
|
|
529
558
|
title: "GTKX symbol reference",
|
|
530
559
|
description:
|
|
531
|
-
"Reference page for one symbol of the project's generated
|
|
560
|
+
"Reference page for one symbol of the project's generated bindings: a JSX element or a " +
|
|
532
561
|
"class, interface, record, enum, callback, alias, function, or constant.",
|
|
533
562
|
mimeType: "text/markdown",
|
|
534
563
|
},
|
package/src/server.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
2
|
import { loadConfig } from "@gtkx/config";
|
|
3
|
-
import { type McpSettings, resolveMcpSettings } from "@gtkx/config/internal";
|
|
3
|
+
import { configDependenciesFor, type McpSettings, resolveMcpSettings } from "@gtkx/config/internal";
|
|
4
4
|
import { createLogger, installGracefulShutdown, type Logger } from "@gtkx/utils";
|
|
5
5
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
7
9
|
import { parseArgs } from "node:util";
|
|
8
10
|
import { z } from "zod";
|
|
9
11
|
import type { ConnectionErrorEvent } from "./transport.js";
|
|
@@ -12,7 +14,6 @@ import { type AppRegisteredEvent, AppRouter, type AppUnregisteredEvent } from ".
|
|
|
12
14
|
import { ConnectionRegistry } from "./connection-registry.js";
|
|
13
15
|
import {
|
|
14
16
|
type AppInfo,
|
|
15
|
-
DEFAULT_SOCKET_PATH,
|
|
16
17
|
DEFAULT_SUBTREE_DEPTH,
|
|
17
18
|
fireEventParams,
|
|
18
19
|
MAX_SUBTREE_WIDGETS,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
type ReferenceProvider,
|
|
30
31
|
registerReferenceResources,
|
|
31
32
|
} from "./reference.js";
|
|
33
|
+
import { resolveMcpSocketAddress } from "./socket-path.js";
|
|
32
34
|
import { SocketServer } from "./socket-server.js";
|
|
33
35
|
import { selectTools } from "./tool-filter.js";
|
|
34
36
|
import { defineTool, imageContent, registerTool, textContent, textError, type Tool } from "./tool.js";
|
|
@@ -53,9 +55,11 @@ type McpServerHandle = {
|
|
|
53
55
|
type ServerLifecycle = {
|
|
54
56
|
socketServer: SocketServer;
|
|
55
57
|
mcpServer: McpServer;
|
|
58
|
+
appRouter: AppRouter;
|
|
56
59
|
socketPath: string;
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
isStopRequested: boolean;
|
|
61
|
+
startup: Promise<void> | null;
|
|
62
|
+
shutdown: Promise<void> | null;
|
|
59
63
|
};
|
|
60
64
|
|
|
61
65
|
type AppWindow = { id: string; title: string | null };
|
|
@@ -68,21 +72,29 @@ const DEFAULT_SETTINGS: McpSettings = { tools: [], isReadOnly: false };
|
|
|
68
72
|
const INSTRUCTIONS =
|
|
69
73
|
"The widget tools drive a GTKX app running under `gtkx dev`: they read " +
|
|
70
74
|
"its live widget tree, query it by accessible role and name, click and type, and capture screenshots. " +
|
|
71
|
-
"They
|
|
75
|
+
"They wait briefly for a starting app, so start `gtkx dev` before or alongside a widget request. " +
|
|
76
|
+
"The reference tools answer from the " +
|
|
72
77
|
"bindings generated for a specific project, so they describe that project's GIR libraries rather than " +
|
|
73
|
-
"
|
|
74
|
-
"does not apply here.\n\n" +
|
|
78
|
+
"the GNOME platform in general; prefer them over recalled Adwaita or GTK knowledge, which is usually C, " +
|
|
79
|
+
"PyGObject or GJS and does not apply here.\n\n" +
|
|
75
80
|
"Widget IDs are valid only while the widget is mounted. After a dialog closes, a list re-renders, or " +
|
|
76
81
|
"fast refresh patches a component, re-read the tree or re-run the query instead of reusing an ID.";
|
|
77
82
|
|
|
78
|
-
const APPLICATION_ID_DESCRIPTION =
|
|
83
|
+
const APPLICATION_ID_DESCRIPTION =
|
|
84
|
+
"Application ID to query. If not specified, uses the first connected app. The tool waits briefly for the " +
|
|
85
|
+
"requested app to register.";
|
|
86
|
+
|
|
87
|
+
const APP_TIMEOUT_DESCRIPTION = "Milliseconds to wait for the requested app to register (default: 10000).";
|
|
79
88
|
|
|
80
89
|
const WIDGET_ID_DESCRIPTION =
|
|
81
90
|
"Widget ID obtained from `gtkx_get_widget_tree`, `gtkx_query_widgets`, or `gtkx_get_widget_props`. " +
|
|
82
91
|
"IDs are scoped to a single app. An ID stays valid for as long as its widget is mounted and stops " +
|
|
83
92
|
"resolving once the widget is unmounted.";
|
|
84
93
|
|
|
85
|
-
const applicationIdShape = {
|
|
94
|
+
const applicationIdShape = {
|
|
95
|
+
applicationId: z.string().optional().describe(APPLICATION_ID_DESCRIPTION),
|
|
96
|
+
appTimeout: z.number().int().nonnegative().optional().describe(APP_TIMEOUT_DESCRIPTION),
|
|
97
|
+
};
|
|
86
98
|
|
|
87
99
|
const widgetIdShape = {
|
|
88
100
|
...applicationIdShape,
|
|
@@ -148,7 +160,7 @@ const fireEventShape = {
|
|
|
148
160
|
...applicationIdShape,
|
|
149
161
|
...describeParams(fireEventParams.shape, {
|
|
150
162
|
widgetId: WIDGET_ID_DESCRIPTION,
|
|
151
|
-
signal: "
|
|
163
|
+
signal: "GObject signal name to emit",
|
|
152
164
|
args: "Arguments to pass to the signal",
|
|
153
165
|
}),
|
|
154
166
|
};
|
|
@@ -165,9 +177,8 @@ const screenshotShape = {
|
|
|
165
177
|
.boolean()
|
|
166
178
|
.optional()
|
|
167
179
|
.describe(
|
|
168
|
-
"
|
|
169
|
-
"
|
|
170
|
-
"conversation until something actually needs to look at it.",
|
|
180
|
+
"Return the PNG as image content (default: true). Set false with `path` to save the image " +
|
|
181
|
+
"and return only its file location.",
|
|
171
182
|
),
|
|
172
183
|
};
|
|
173
184
|
|
|
@@ -220,11 +231,13 @@ const listAppsTool = (appRouter: AppRouter): Tool =>
|
|
|
220
231
|
name: "gtkx_list_apps",
|
|
221
232
|
title: "List apps",
|
|
222
233
|
kind: "readOnly",
|
|
223
|
-
description:
|
|
234
|
+
description:
|
|
235
|
+
"List connected GTKX applications and their open windows. " +
|
|
236
|
+
"Returns the current snapshot immediately unless waitForApps is true.",
|
|
224
237
|
inputSchema: listAppsShape,
|
|
225
238
|
handler: async ({ waitForApps, timeout }) => {
|
|
226
239
|
if (waitForApps && !appRouter.hasConnectedApps()) {
|
|
227
|
-
await appRouter.waitForApp(timeout);
|
|
240
|
+
await appRouter.waitForApp(undefined, timeout);
|
|
228
241
|
}
|
|
229
242
|
|
|
230
243
|
const apps = appRouter.getApps();
|
|
@@ -266,11 +279,12 @@ const screenshotTool = (appRouter: AppRouter): Tool =>
|
|
|
266
279
|
"the PNG to `path` on the app's machine. You can't target widgets from a screenshot; use " +
|
|
267
280
|
"`gtkx_get_widget_tree` to find widget IDs for interaction.",
|
|
268
281
|
inputSchema: screenshotShape,
|
|
269
|
-
handler: async ({ applicationId, returnImage, ...params }) => {
|
|
282
|
+
handler: async ({ applicationId, appTimeout, returnImage, ...params }) => {
|
|
270
283
|
const result = await appRouter.sendToApp<{ data: string; mimeType: string; savedPath?: string }>(
|
|
271
284
|
applicationId,
|
|
272
285
|
"widget.screenshot",
|
|
273
286
|
params,
|
|
287
|
+
appTimeout,
|
|
274
288
|
);
|
|
275
289
|
|
|
276
290
|
return screenshotResult(result, returnImage !== false);
|
|
@@ -283,21 +297,17 @@ const widgetPropsTool = (appRouter: AppRouter): Tool =>
|
|
|
283
297
|
title: "Get widget properties",
|
|
284
298
|
kind: "readOnly",
|
|
285
299
|
description:
|
|
286
|
-
"
|
|
287
|
-
|
|
288
|
-
`
|
|
289
|
-
|
|
290
|
-
"
|
|
291
|
-
"
|
|
292
|
-
"
|
|
293
|
-
"
|
|
294
|
-
"`{type, value}`, where an enum or flags value is its GType value name, a 64-bit integer a " +
|
|
295
|
-
"decimal string, and an object its GType name plus `widgetId` when it is a widget. Asking for " +
|
|
296
|
-
"a property the widget does not have fails; a value that cannot be marshalled carries a `note` " +
|
|
297
|
-
"instead.",
|
|
300
|
+
"Read a widget's type, accessible role, name, text, sensitivity, visibility, CSS classes, " +
|
|
301
|
+
`and descendants. Defaults to ${String(DEFAULT_SUBTREE_DEPTH)} levels; \`maxDepth\` changes ` +
|
|
302
|
+
`the depth. Results contain at most ${String(MAX_SUBTREE_WIDGETS)} widgets, in breadth-first ` +
|
|
303
|
+
"order. `hiddenChildren` counts omitted direct children; query that widget's ID to expand it. " +
|
|
304
|
+
"Use `gtkx_get_widget_tree` for an overview. Requested GObject `properties` appear first, " +
|
|
305
|
+
"keyed by canonical kebab-case name with `{type, value}`. Enum/flags values use GType value " +
|
|
306
|
+
"names, 64-bit integers use decimal strings, and objects use their GType name plus `widgetId` " +
|
|
307
|
+
"for widgets. Unknown properties fail; values that cannot be converted include a `note`.",
|
|
298
308
|
inputSchema: widgetPropsShape,
|
|
299
|
-
handler: async ({ applicationId, ...params }) => {
|
|
300
|
-
const result = await appRouter.sendToApp(applicationId, "widget.getProps", params);
|
|
309
|
+
handler: async ({ applicationId, appTimeout, ...params }) => {
|
|
310
|
+
const result = await appRouter.sendToApp(applicationId, "widget.getProps", params, appTimeout);
|
|
301
311
|
|
|
302
312
|
return textContent(JSON.stringify(result, null, 2));
|
|
303
313
|
},
|
|
@@ -315,11 +325,11 @@ function buildInspectionTools(appRouter: AppRouter): Tool[] {
|
|
|
315
325
|
"types, roles, and properties. For large apps, pass `maxDepth` for a shallow overview and/or " +
|
|
316
326
|
"`rootId` to render just one subtree instead of the whole (possibly truncated) tree.",
|
|
317
327
|
inputSchema: treeShape,
|
|
318
|
-
handler: async ({ applicationId, rootId, maxDepth }) => {
|
|
328
|
+
handler: async ({ applicationId, appTimeout, rootId, maxDepth }) => {
|
|
319
329
|
const result = await appRouter.sendToApp<{ tree: string }>(applicationId, "widget.getTree", {
|
|
320
330
|
rootId,
|
|
321
331
|
maxDepth,
|
|
322
|
-
});
|
|
332
|
+
}, appTimeout);
|
|
323
333
|
|
|
324
334
|
return textContent(result.tree);
|
|
325
335
|
},
|
|
@@ -334,8 +344,8 @@ function buildInspectionTools(appRouter: AppRouter): Tool[] {
|
|
|
334
344
|
"children carries `hiddenChildren`, the count of its direct children left out. Read a " +
|
|
335
345
|
"match's subtree with `gtkx_get_widget_props` or `gtkx_get_widget_tree`.",
|
|
336
346
|
inputSchema: queryWidgetsShape,
|
|
337
|
-
handler: async ({ applicationId, ...params }) => {
|
|
338
|
-
const result = await appRouter.sendToApp(applicationId, "widget.query", params);
|
|
347
|
+
handler: async ({ applicationId, appTimeout, ...params }) => {
|
|
348
|
+
const result = await appRouter.sendToApp(applicationId, "widget.query", params, appTimeout);
|
|
339
349
|
|
|
340
350
|
return textContent(JSON.stringify(result, null, 2));
|
|
341
351
|
},
|
|
@@ -352,12 +362,12 @@ function buildInteractionTools(appRouter: AppRouter): Tool[] {
|
|
|
352
362
|
title: "Click widget",
|
|
353
363
|
kind: "action",
|
|
354
364
|
description:
|
|
355
|
-
"Click a widget through userEvent.click
|
|
365
|
+
"Click a widget through userEvent.click. Works with " +
|
|
356
366
|
"buttons, checkboxes, switches, list and grid rows, tree expanders, and column headers: a " +
|
|
357
367
|
"row is selected, an expander toggles its row's expansion, and a header sorts its column.",
|
|
358
368
|
inputSchema: widgetIdShape,
|
|
359
|
-
handler: async ({ applicationId, ...params }) => {
|
|
360
|
-
await appRouter.sendToApp(applicationId, "widget.click", params);
|
|
369
|
+
handler: async ({ applicationId, appTimeout, ...params }) => {
|
|
370
|
+
await appRouter.sendToApp(applicationId, "widget.click", params, appTimeout);
|
|
361
371
|
|
|
362
372
|
return textContent("Clicked");
|
|
363
373
|
},
|
|
@@ -368,8 +378,8 @@ function buildInteractionTools(appRouter: AppRouter): Tool[] {
|
|
|
368
378
|
kind: "action",
|
|
369
379
|
description: "Type text into an editable widget like Entry or TextView",
|
|
370
380
|
inputSchema: typeShape,
|
|
371
|
-
handler: async ({ applicationId, ...params }) => {
|
|
372
|
-
await appRouter.sendToApp(applicationId, "widget.type", params);
|
|
381
|
+
handler: async ({ applicationId, appTimeout, ...params }) => {
|
|
382
|
+
await appRouter.sendToApp(applicationId, "widget.type", params, appTimeout);
|
|
373
383
|
|
|
374
384
|
return textContent("Typed text");
|
|
375
385
|
},
|
|
@@ -378,10 +388,10 @@ function buildInteractionTools(appRouter: AppRouter): Tool[] {
|
|
|
378
388
|
name: "gtkx_fire_event",
|
|
379
389
|
title: "Fire event",
|
|
380
390
|
kind: "action",
|
|
381
|
-
description: "Emit a
|
|
391
|
+
description: "Emit a GObject signal on a widget. Use this for custom interactions.",
|
|
382
392
|
inputSchema: fireEventShape,
|
|
383
|
-
handler: async ({ applicationId, ...params }) => {
|
|
384
|
-
const result = await appRouter.sendToApp(applicationId, "widget.fireEvent", params);
|
|
393
|
+
handler: async ({ applicationId, appTimeout, ...params }) => {
|
|
394
|
+
const result = await appRouter.sendToApp(applicationId, "widget.fireEvent", params, appTimeout);
|
|
385
395
|
|
|
386
396
|
return textContent(JSON.stringify(result, null, 2));
|
|
387
397
|
},
|
|
@@ -410,42 +420,97 @@ const registerTools = (
|
|
|
410
420
|
}
|
|
411
421
|
};
|
|
412
422
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
423
|
+
const stoppedServerError = (): Error => new Error("createMcpServer: a stopped server cannot be started again");
|
|
424
|
+
|
|
425
|
+
const wasStopRequested = (state: ServerLifecycle): boolean => state.isStopRequested;
|
|
426
|
+
|
|
427
|
+
function stopServer(state: ServerLifecycle): Promise<void> {
|
|
428
|
+
if (state.shutdown !== null) {
|
|
429
|
+
return state.shutdown;
|
|
416
430
|
}
|
|
417
431
|
|
|
418
|
-
state.
|
|
419
|
-
|
|
420
|
-
|
|
432
|
+
state.isStopRequested = true;
|
|
433
|
+
state.appRouter.dispose();
|
|
434
|
+
const startup = state.startup;
|
|
435
|
+
const shutdown = (async (): Promise<void> => {
|
|
436
|
+
if (startup !== null) {
|
|
437
|
+
await Promise.allSettled([startup]);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
await state.socketServer.stop();
|
|
441
|
+
await state.mcpServer.close();
|
|
442
|
+
})();
|
|
443
|
+
|
|
444
|
+
state.shutdown = shutdown;
|
|
445
|
+
|
|
446
|
+
return shutdown;
|
|
421
447
|
}
|
|
422
448
|
|
|
423
|
-
|
|
424
|
-
if (state
|
|
425
|
-
|
|
449
|
+
function startServer(state: ServerLifecycle): Promise<void> {
|
|
450
|
+
if (wasStopRequested(state)) {
|
|
451
|
+
return Promise.reject(stoppedServerError());
|
|
426
452
|
}
|
|
427
453
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
if (state.isStarted) {
|
|
431
|
-
return;
|
|
454
|
+
if (state.startup !== null) {
|
|
455
|
+
return state.startup;
|
|
432
456
|
}
|
|
433
457
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
458
|
+
const startup = (async (): Promise<void> => {
|
|
459
|
+
try {
|
|
460
|
+
await state.socketServer.start();
|
|
461
|
+
|
|
462
|
+
if (wasStopRequested(state)) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
log.info(`socket server listening on ${state.socketPath}`);
|
|
467
|
+
await connectStdio(state.mcpServer, () => stopServer(state));
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (!wasStopRequested(state)) {
|
|
470
|
+
state.isStopRequested = true;
|
|
471
|
+
state.appRouter.dispose();
|
|
472
|
+
await state.socketServer.stop();
|
|
473
|
+
await state.mcpServer.close();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
})();
|
|
479
|
+
|
|
480
|
+
state.startup = startup;
|
|
481
|
+
|
|
482
|
+
return startup;
|
|
437
483
|
}
|
|
438
484
|
|
|
439
|
-
const createServerHandle = (
|
|
440
|
-
|
|
485
|
+
const createServerHandle = (
|
|
486
|
+
socketServer: SocketServer,
|
|
487
|
+
mcpServer: McpServer,
|
|
488
|
+
appRouter: AppRouter,
|
|
489
|
+
socketPath: string,
|
|
490
|
+
): McpServerHandle => {
|
|
491
|
+
const state: ServerLifecycle = {
|
|
492
|
+
socketServer,
|
|
493
|
+
mcpServer,
|
|
494
|
+
appRouter,
|
|
495
|
+
socketPath,
|
|
496
|
+
isStopRequested: false,
|
|
497
|
+
startup: null,
|
|
498
|
+
shutdown: null,
|
|
499
|
+
};
|
|
441
500
|
|
|
442
501
|
return { start: () => startServer(state), stop: () => stopServer(state) };
|
|
443
502
|
};
|
|
444
503
|
|
|
445
504
|
const createMcpServer = (options: CreateMcpServerOptions): McpServerHandle => {
|
|
446
|
-
const
|
|
505
|
+
const socketAddress = resolveMcpSocketAddress(options.socketPath);
|
|
506
|
+
const socketPath = socketAddress.path;
|
|
507
|
+
|
|
508
|
+
if (socketAddress.fallbackDirectory !== null) {
|
|
509
|
+
log.warn(`MCP socket path exceeds the safe Unix byte budget; using private fallback ${socketPath}`);
|
|
510
|
+
}
|
|
511
|
+
|
|
447
512
|
const registry = new ConnectionRegistry();
|
|
448
|
-
const socketServer = new SocketServer(registry,
|
|
513
|
+
const socketServer = new SocketServer(registry, socketAddress);
|
|
449
514
|
const appRouter = new AppRouter(registry);
|
|
450
515
|
registry.addEventListener("error", logSocketError);
|
|
451
516
|
|
|
@@ -463,7 +528,7 @@ const createMcpServer = (options: CreateMcpServerOptions): McpServerHandle => {
|
|
|
463
528
|
registerTools(mcpServer, appRouter, referenceProvider, options.settings ?? DEFAULT_SETTINGS);
|
|
464
529
|
registerReferenceResources(mcpServer, referenceProvider);
|
|
465
530
|
|
|
466
|
-
return createServerHandle(socketServer, mcpServer, socketPath);
|
|
531
|
+
return createServerHandle(socketServer, mcpServer, appRouter, socketPath);
|
|
467
532
|
};
|
|
468
533
|
|
|
469
534
|
const configuredSettings = async (cwd: string): Promise<McpSettings> => {
|
|
@@ -471,7 +536,11 @@ const configuredSettings = async (cwd: string): Promise<McpSettings> => {
|
|
|
471
536
|
const { config } = await loadConfig(cwd);
|
|
472
537
|
|
|
473
538
|
return resolveMcpSettings(config);
|
|
474
|
-
} catch {
|
|
539
|
+
} catch (error) {
|
|
540
|
+
if (configDependenciesFor(error).some((path) => existsSync(resolve(cwd, path)))) {
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
|
|
475
544
|
return DEFAULT_SETTINGS;
|
|
476
545
|
}
|
|
477
546
|
};
|
|
@@ -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
|
+
};
|