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