@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.
Files changed (44) hide show
  1. package/README.md +6 -169
  2. package/dist/app-router.d.ts +7 -7
  3. package/dist/app-router.d.ts.map +1 -1
  4. package/dist/app-router.js +63 -32
  5. package/dist/app-router.js.map +1 -1
  6. package/dist/connection-registry.d.ts +0 -1
  7. package/dist/connection-registry.d.ts.map +1 -1
  8. package/dist/connection-registry.js +0 -3
  9. package/dist/connection-registry.js.map +1 -1
  10. package/dist/internal.d.ts +2 -1
  11. package/dist/internal.d.ts.map +1 -1
  12. package/dist/internal.js +2 -1
  13. package/dist/internal.js.map +1 -1
  14. package/dist/protocol/schemas.d.ts +2 -7
  15. package/dist/protocol/schemas.d.ts.map +1 -1
  16. package/dist/protocol/schemas.js +1 -7
  17. package/dist/protocol/schemas.js.map +1 -1
  18. package/dist/reference.d.ts.map +1 -1
  19. package/dist/reference.js +32 -18
  20. package/dist/reference.js.map +1 -1
  21. package/dist/server.d.ts.map +1 -1
  22. package/dist/server.js +110 -62
  23. package/dist/server.js.map +1 -1
  24. package/dist/socket-path.d.ts +11 -0
  25. package/dist/socket-path.d.ts.map +1 -0
  26. package/dist/socket-path.js +74 -0
  27. package/dist/socket-path.js.map +1 -0
  28. package/dist/socket-server.d.ts +3 -1
  29. package/dist/socket-server.d.ts.map +1 -1
  30. package/dist/socket-server.js +48 -32
  31. package/dist/socket-server.js.map +1 -1
  32. package/dist/tool-filter.d.ts.map +1 -1
  33. package/dist/tool-filter.js +1 -3
  34. package/dist/tool-filter.js.map +1 -1
  35. package/package.json +22 -8
  36. package/src/app-router.ts +84 -39
  37. package/src/connection-registry.ts +0 -3
  38. package/src/internal.ts +4 -1
  39. package/src/protocol/schemas.ts +1 -14
  40. package/src/reference.ts +48 -19
  41. package/src/server.ts +133 -64
  42. package/src/socket-path.ts +103 -0
  43. package/src/socket-server.ts +61 -37
  44. package/src/tool-filter.ts +1 -4
package/src/reference.ts CHANGED
@@ -1,5 +1,20 @@
1
- import { type ApiReference, type ApiSymbol, loadApiReference, resolveGirPath, resolveLibraries } from "@gtkx/codegen";
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 CONFIG_EXTENSIONS = ["ts", "mts", "js", "mjs", "json"];
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 (`Gtk.Button`, `Gtk.Orientation`, `GLib.idleAdd`), JSX element name (`GtkButton`), " +
83
- "or bare symbol name when unambiguous (`Button`).";
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. `Gtk`, `Adw`, `Gio`). Omit for an overview of all namespaces."),
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. `Gtk`)."),
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 { config, configFile, root } = await loadConfig(requestedRoot);
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 = [watchFile(resolve(root, configFile)), ...reference.girFiles.map((file) => watchFile(file))];
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 GTK4 bindings API (`@gtkx/gi` and `@gtkx/jsx`). Without a namespace, " +
358
- "returns every namespace with symbol counts; with a namespace, lists all of its symbols grouped by kind.",
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 GTK4 bindings API by symbol name. Returns matching symbols with " +
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 GTK4 bindings: JSX elements " +
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 GTK4 bindings, with symbol and JSX element counts.",
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 GTK4 bindings, grouped by kind.",
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 GTK4 bindings: a JSX element or a " +
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
- isStopped: boolean;
58
- isStarted: boolean;
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 fail until an app is running, so start `gtkx dev` first. The reference tools answer from the " +
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
- "GTK in general; prefer them over recalled GTK knowledge, which is usually C, PyGObject or GJS and " +
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 = "Application ID to query. If not specified, uses the first connected app.";
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 = { applicationId: z.string().optional().describe(APPLICATION_ID_DESCRIPTION) };
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: "GTK4 signal name to emit",
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
- "Whether to return the PNG as image content, true by default. Pass false together with `path` " +
169
- "to save the screenshot and get back only where it landed, which keeps the image out of the " +
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: "List all connected GTKX applications and their open windows.",
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
- "Get a fixed summary of one widget by ID: type, accessible role, name, text, sensitivity, " +
287
- "visibility, CSS classes, and the same summary for its descendants. The subtree is bounded " +
288
- `twice: ${String(DEFAULT_SUBTREE_DEPTH)} levels deep unless \`maxDepth\` says otherwise, and ` +
289
- `${String(MAX_SUBTREE_WIDGETS)} widgets in all, filled breadth first. Wherever either bound ` +
290
- "cut a branch, that widget carries `hiddenChildren`, the count of its own direct children left " +
291
- "out rather than of everything below them; call again with that widget's ID to drill in, or " +
292
- "use `gtkx_get_widget_tree` for a wider map. Pass `properties` to read GObject properties too; " +
293
- "they come back first in the payload, each under its canonical kebab-case name as " +
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, with no special case per widget. Works with " +
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 GTK4 signal on a widget. Use this for custom interactions.",
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
- async function stopServer(state: ServerLifecycle): Promise<void> {
414
- if (state.isStopped) {
415
- return;
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.isStopped = true;
419
- await state.socketServer.stop();
420
- await state.mcpServer.close();
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
- async function startServer(state: ServerLifecycle): Promise<void> {
424
- if (state.isStopped) {
425
- throw new Error("createMcpServer: a stopped server cannot be started again");
449
+ function startServer(state: ServerLifecycle): Promise<void> {
450
+ if (wasStopRequested(state)) {
451
+ return Promise.reject(stoppedServerError());
426
452
  }
427
453
 
428
- await state.socketServer.start();
429
-
430
- if (state.isStarted) {
431
- return;
454
+ if (state.startup !== null) {
455
+ return state.startup;
432
456
  }
433
457
 
434
- state.isStarted = true;
435
- log.info(`socket server listening on ${state.socketPath}`);
436
- await connectStdio(state.mcpServer, () => stopServer(state));
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 = (socketServer: SocketServer, mcpServer: McpServer, socketPath: string): McpServerHandle => {
440
- const state: ServerLifecycle = { socketServer, mcpServer, socketPath, isStopped: false, isStarted: false };
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 socketPath = options.socketPath ?? DEFAULT_SOCKET_PATH;
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, socketPath);
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
+ };