@gtkx/mcp 1.0.0-rc.3 → 1.0.0

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/src/server.ts CHANGED
@@ -9,14 +9,22 @@ import { ConnectionRegistry } from "./connection-registry.js";
9
9
  import {
10
10
  type AppInfo,
11
11
  DEFAULT_SOCKET_PATH,
12
+ DEFAULT_SUBTREE_DEPTH,
12
13
  fireEventParams,
14
+ MAX_SUBTREE_WIDGETS,
13
15
  queryParams,
14
16
  screenshotParams,
15
17
  treeParams,
16
18
  typeParams,
17
19
  widgetIdParams,
20
+ widgetPropsParams,
18
21
  } from "./protocol/schemas.js";
19
- import { buildReferenceTools, createReferenceProvider, registerReferenceResources } from "./reference.js";
22
+ import {
23
+ buildReferenceTools,
24
+ createReferenceProvider,
25
+ type ReferenceProvider,
26
+ registerReferenceResources,
27
+ } from "./reference.js";
20
28
  import { SocketServer } from "./socket-server.js";
21
29
  import { defineTool, imageContent, registerTool, textContent, type Tool } from "./tool.js";
22
30
 
@@ -30,6 +38,14 @@ type McpServerHandle = {
30
38
  stop(): Promise<void>;
31
39
  };
32
40
 
41
+ type ServerLifecycle = {
42
+ socketServer: SocketServer;
43
+ mcpServer: McpServer;
44
+ socketPath: string;
45
+ isStopped: boolean;
46
+ isStarted: boolean;
47
+ };
48
+
33
49
  type AppWindow = { id: string; title: string | null };
34
50
  type AppWithWindows = AppInfo & { windows?: AppWindow[] };
35
51
 
@@ -47,18 +63,34 @@ const applicationIdShape = { applicationId: z.string().optional().describe(APPLI
47
63
 
48
64
  const widgetIdShape = {
49
65
  ...applicationIdShape,
50
- widgetId: widgetIdParams.shape.widgetId.describe(WIDGET_ID_DESCRIPTION),
66
+ ...describeParams(widgetIdParams.shape, { widgetId: WIDGET_ID_DESCRIPTION }),
67
+ };
68
+
69
+ const widgetPropsShape = {
70
+ ...applicationIdShape,
71
+ ...describeParams(widgetPropsParams.shape, {
72
+ widgetId: WIDGET_ID_DESCRIPTION,
73
+ properties:
74
+ "GObject property names to read as well, in kebab-case or camelCase (\"current-breakpoint\" or " +
75
+ "\"currentBreakpoint\"). Omit for the summary alone.",
76
+ maxDepth:
77
+ `How many levels of descendants to include: ${String(DEFAULT_SUBTREE_DEPTH)} by default, and 0 ` +
78
+ `for the widget on its own. At most ${String(MAX_SUBTREE_WIDGETS)} widgets come back whatever ` +
79
+ "the depth, and any widget whose own direct children were left out carries a `hiddenChildren` count.",
80
+ }),
51
81
  };
52
82
 
53
83
  const treeShape = {
54
84
  ...applicationIdShape,
55
- rootId: treeParams.shape.rootId.describe(
56
- "Render only the subtree rooted at this widget ID (from a prior tree or query). Omit for the whole app.",
57
- ),
58
- maxDepth: treeParams.shape.maxDepth.describe(
59
- "Limit how many levels deep to render; deeper descendants are summarized with a count. " +
60
- "Combine with rootId to drill in without dumping the whole tree.",
61
- ),
85
+ ...describeParams(treeParams.shape, {
86
+ rootId:
87
+ "Render only the subtree rooted at this widget ID (from a prior tree or query). Omit for the " +
88
+ "whole app.",
89
+ maxDepth:
90
+ "Limit how many levels deep to render, and 0 for the root widget on its own; deeper " +
91
+ "descendants are summarized with a count. Combine with rootId to drill in without dumping " +
92
+ "the whole tree.",
93
+ }),
62
94
  };
63
95
 
64
96
  const listAppsShape = {
@@ -73,32 +105,58 @@ const listAppsShape = {
73
105
 
74
106
  const queryWidgetsShape = {
75
107
  ...applicationIdShape,
76
- by: queryParams.shape.by.describe("Query type"),
77
- value: queryParams.shape.value.describe("Value to search for"),
78
- options: queryParams.shape.options.describe("Additional query options"),
108
+ ...describeParams(queryParams.shape, {
109
+ by: "Query type",
110
+ value: "Value to search for",
111
+ options: "Additional query options",
112
+ }),
79
113
  };
80
114
 
81
115
  const typeShape = {
82
- ...widgetIdShape,
83
- text: typeParams.shape.text.describe("Text to type"),
84
- clear: typeParams.shape.clear.describe("Clear existing text before typing"),
116
+ ...applicationIdShape,
117
+ ...describeParams(typeParams.shape, {
118
+ widgetId: WIDGET_ID_DESCRIPTION,
119
+ text: "Text to type",
120
+ clear: "Clear existing text before typing",
121
+ }),
85
122
  };
86
123
 
87
124
  const fireEventShape = {
88
- ...widgetIdShape,
89
- signal: fireEventParams.shape.signal.describe("GTK4 signal name to emit"),
90
- args: fireEventParams.shape.args.describe("Arguments to pass to the signal"),
125
+ ...applicationIdShape,
126
+ ...describeParams(fireEventParams.shape, {
127
+ widgetId: WIDGET_ID_DESCRIPTION,
128
+ signal: "GTK4 signal name to emit",
129
+ args: "Arguments to pass to the signal",
130
+ }),
91
131
  };
92
132
 
93
133
  const screenshotShape = {
94
134
  ...applicationIdShape,
95
- windowId: screenshotParams.shape.windowId.describe(
96
- "Window ID to capture. If not specified, captures the first window.",
97
- ),
98
- path: screenshotParams.shape.path.describe(
99
- "Absolute path to write the PNG to on the app's machine. If set, the screenshot is saved there " +
100
- "in addition to being returned.",
101
- ),
135
+ ...describeParams(screenshotParams.shape, {
136
+ windowId: "Window ID to capture. If not specified, captures the first window.",
137
+ path:
138
+ "Absolute path to write the PNG to on the app's machine. If set, the screenshot is saved there " +
139
+ "in addition to being returned.",
140
+ }),
141
+ };
142
+
143
+ function describeParams<Shape extends Record<string, z.ZodType>>(
144
+ shape: Shape,
145
+ descriptions: { [Key in keyof Shape]: string },
146
+ ): Shape {
147
+ const described: [string, z.ZodType][] = Object.entries(shape).map(([key, schema]) => [
148
+ key,
149
+ schema.describe(descriptions[key as keyof Shape]),
150
+ ]);
151
+
152
+ return Object.fromEntries(described) as Shape;
153
+ }
154
+
155
+ const connectStdio = async (mcpServer: McpServer, stop: () => Promise<void>): Promise<void> => {
156
+ const transport = new StdioServerTransport();
157
+ process.stdin.on("end", () => void stop());
158
+ process.stdin.on("close", () => void stop());
159
+ await mcpServer.connect(transport);
102
160
  };
103
161
 
104
162
  const logSocketError = (event: Event): void => {
@@ -175,6 +233,32 @@ const screenshotTool = (appRouter: AppRouter): Tool =>
175
233
  },
176
234
  });
177
235
 
236
+ const widgetPropsTool = (appRouter: AppRouter): Tool =>
237
+ defineTool({
238
+ name: "gtkx_get_widget_props",
239
+ title: "Get widget properties",
240
+ kind: "readOnly",
241
+ description:
242
+ "Get a fixed summary of one widget by ID: type, accessible role, name, text, sensitivity, " +
243
+ "visibility, CSS classes, and the same summary for its descendants. The subtree is bounded " +
244
+ `twice: ${String(DEFAULT_SUBTREE_DEPTH)} levels deep unless \`maxDepth\` says otherwise, and ` +
245
+ `${String(MAX_SUBTREE_WIDGETS)} widgets in all, filled breadth first. Wherever either bound ` +
246
+ "cut a branch, that widget carries `hiddenChildren`, the count of its own direct children left " +
247
+ "out rather than of everything below them; call again with that widget's ID to drill in, or " +
248
+ "use `gtkx_get_widget_tree` for a wider map. Pass `properties` to read GObject properties too; " +
249
+ "they come back first in the payload, each under its canonical kebab-case name as " +
250
+ "`{type, value}`, where an enum or flags value is its GType value name, a 64-bit integer a " +
251
+ "decimal string, and an object its GType name plus `widgetId` when it is a widget. Asking for " +
252
+ "a property the widget does not have fails; a value that cannot be marshalled carries a `note` " +
253
+ "instead.",
254
+ inputSchema: widgetPropsShape,
255
+ handler: async ({ applicationId, ...params }) => {
256
+ const result = await appRouter.sendToApp(applicationId, "widget.getProps", params);
257
+
258
+ return textContent(JSON.stringify(result, null, 2));
259
+ },
260
+ });
261
+
178
262
  function buildInspectionTools(appRouter: AppRouter): Tool[] {
179
263
  return [
180
264
  listAppsTool(appRouter),
@@ -201,7 +285,10 @@ function buildInspectionTools(appRouter: AppRouter): Tool[] {
201
285
  title: "Query widgets",
202
286
  kind: "readOnly",
203
287
  description:
204
- "Find widgets by role, text, name, or label. Returns matching widgets with their IDs and properties.",
288
+ "Find widgets by role, text, name, or label. Returns each match with its ID and the same " +
289
+ "fixed summary `gtkx_get_widget_props` returns, with no descendants: a match that has " +
290
+ "children carries `hiddenChildren`, the count of its direct children left out. Read a " +
291
+ "match's subtree with `gtkx_get_widget_props` or `gtkx_get_widget_tree`.",
205
292
  inputSchema: queryWidgetsShape,
206
293
  handler: async ({ applicationId, ...params }) => {
207
294
  const result = await appRouter.sendToApp(applicationId, "widget.query", params);
@@ -209,21 +296,7 @@ function buildInspectionTools(appRouter: AppRouter): Tool[] {
209
296
  return textContent(JSON.stringify(result, null, 2));
210
297
  },
211
298
  }),
212
- defineTool({
213
- name: "gtkx_get_widget_props",
214
- title: "Get widget properties",
215
- kind: "readOnly",
216
- description:
217
- "Get a fixed summary of one widget by ID: type, accessible role, name, text, sensitivity, " +
218
- "visibility, CSS classes, and the full subtree of descendant widgets. It does not return " +
219
- "arbitrary GObject properties.",
220
- inputSchema: widgetIdShape,
221
- handler: async ({ applicationId, ...params }) => {
222
- const result = await appRouter.sendToApp(applicationId, "widget.getProps", params);
223
-
224
- return textContent(JSON.stringify(result, null, 2));
225
- },
226
- }),
299
+ widgetPropsTool(appRouter),
227
300
  screenshotTool(appRouter),
228
301
  ];
229
302
  }
@@ -234,7 +307,10 @@ function buildInteractionTools(appRouter: AppRouter): Tool[] {
234
307
  name: "gtkx_click",
235
308
  title: "Click widget",
236
309
  kind: "action",
237
- description: "Click a widget. Works with buttons, checkboxes, and other interactive widgets.",
310
+ description:
311
+ "Click a widget through userEvent.click, with no special case per widget. Works with " +
312
+ "buttons, checkboxes, switches, list and grid rows, tree expanders, and column headers: a " +
313
+ "row is selected, an expander toggles its row's expansion, and a header sorts its column.",
238
314
  inputSchema: widgetIdShape,
239
315
  handler: async ({ applicationId, ...params }) => {
240
316
  await appRouter.sendToApp(applicationId, "widget.click", params);
@@ -261,9 +337,9 @@ function buildInteractionTools(appRouter: AppRouter): Tool[] {
261
337
  description: "Emit a GTK4 signal on a widget. Use this for custom interactions.",
262
338
  inputSchema: fireEventShape,
263
339
  handler: async ({ applicationId, ...params }) => {
264
- await appRouter.sendToApp(applicationId, "widget.fireEvent", params);
340
+ const result = await appRouter.sendToApp(applicationId, "widget.fireEvent", params);
265
341
 
266
- return textContent("Fired event");
342
+ return textContent(JSON.stringify(result, null, 2));
267
343
  },
268
344
  }),
269
345
  ];
@@ -273,6 +349,44 @@ function buildTools(appRouter: AppRouter): Tool[] {
273
349
  return [...buildInspectionTools(appRouter), ...buildInteractionTools(appRouter)];
274
350
  }
275
351
 
352
+ const registerTools = (mcpServer: McpServer, appRouter: AppRouter, provider: ReferenceProvider): void => {
353
+ for (const tool of [...buildTools(appRouter), ...buildReferenceTools(provider)]) {
354
+ registerTool(mcpServer, tool);
355
+ }
356
+ };
357
+
358
+ async function stopServer(state: ServerLifecycle): Promise<void> {
359
+ if (state.isStopped) {
360
+ return;
361
+ }
362
+
363
+ state.isStopped = true;
364
+ await state.socketServer.stop();
365
+ await state.mcpServer.close();
366
+ }
367
+
368
+ async function startServer(state: ServerLifecycle): Promise<void> {
369
+ if (state.isStopped) {
370
+ throw new Error("createMcpServer: a stopped server cannot be started again");
371
+ }
372
+
373
+ await state.socketServer.start();
374
+
375
+ if (state.isStarted) {
376
+ return;
377
+ }
378
+
379
+ state.isStarted = true;
380
+ log.info(`socket server listening on ${state.socketPath}`);
381
+ await connectStdio(state.mcpServer, () => stopServer(state));
382
+ }
383
+
384
+ const createServerHandle = (socketServer: SocketServer, mcpServer: McpServer, socketPath: string): McpServerHandle => {
385
+ const state: ServerLifecycle = { socketServer, mcpServer, socketPath, isStopped: false, isStarted: false };
386
+
387
+ return { start: () => startServer(state), stop: () => stopServer(state) };
388
+ };
389
+
276
390
  const createMcpServer = (options: CreateMcpServerOptions): McpServerHandle => {
277
391
  const socketPath = options.socketPath ?? DEFAULT_SOCKET_PATH;
278
392
  const registry = new ConnectionRegistry();
@@ -290,35 +404,11 @@ const createMcpServer = (options: CreateMcpServerOptions): McpServerHandle => {
290
404
  });
291
405
 
292
406
  const mcpServer = new McpServer({ name: "gtkx-mcp", version: options.version });
293
- const referenceProvider = createReferenceProvider(() => appRouter.getProjectRoot() ?? process.cwd());
294
-
295
- for (const tool of [...buildTools(appRouter), ...buildReferenceTools(referenceProvider)]) {
296
- registerTool(mcpServer, tool);
297
- }
298
-
407
+ const referenceProvider = createReferenceProvider({ getAppRoot: () => appRouter.getProjectRoot() });
408
+ registerTools(mcpServer, appRouter, referenceProvider);
299
409
  registerReferenceResources(mcpServer, referenceProvider);
300
- let isStopped = false;
301
-
302
- const stop = async (): Promise<void> => {
303
- if (isStopped) {
304
- return;
305
- }
306
-
307
- isStopped = true;
308
- await socketServer.stop();
309
- await mcpServer.close();
310
- };
311
-
312
- const start = async (): Promise<void> => {
313
- await socketServer.start();
314
- log.info(`socket server listening on ${socketPath}`);
315
- const transport = new StdioServerTransport();
316
- process.stdin.on("end", () => void stop());
317
- process.stdin.on("close", () => void stop());
318
- await mcpServer.connect(transport);
319
- };
320
-
321
- return { start, stop };
410
+
411
+ return createServerHandle(socketServer, mcpServer, socketPath);
322
412
  };
323
413
 
324
414
  async function main(): Promise<void> {