@gtkx/mcp 1.6.0 → 2.0.0-beta.10

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/app-router.ts CHANGED
@@ -16,6 +16,7 @@ import { type AppInfo, RegisterParamsSchema } from "./protocol/schemas.js";
16
16
 
17
17
  type AppRegisteredEvent = CustomEvent<AppInfo>;
18
18
  type AppUnregisteredEvent = CustomEvent<string>;
19
+ type PendingAppWait = { reject: (error: Error) => void };
19
20
 
20
21
  type RegisteredApp = {
21
22
  info: AppInfo;
@@ -24,6 +25,14 @@ type RegisteredApp = {
24
25
 
25
26
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
26
27
 
28
+ const routerStoppedError = (): Error => new Error("GTKX MCP server stopped while waiting for an application");
29
+
30
+ const appWaitTimeoutError = (timeout: number): Error =>
31
+ new Error(
32
+ `Timeout waiting for app registration after ${String(timeout)}ms. ` +
33
+ "Make sure your GTKX app is running with 'gtkx dev'.",
34
+ );
35
+
27
36
  function appRegisteredEvent(info: AppInfo): AppRegisteredEvent {
28
37
  return new CustomEvent("appRegistered", { detail: info });
29
38
  }
@@ -43,6 +52,10 @@ class AppRouter extends EventTarget {
43
52
 
44
53
  private connections: AppConnections;
45
54
 
55
+ private pendingAppWaits: Set<PendingAppWait> = new Set();
56
+
57
+ private isDisposed = false;
58
+
46
59
  constructor(connections: AppConnections, options: { requestTimeout?: number } = {}) {
47
60
  super();
48
61
  this.connections = connections;
@@ -69,6 +82,10 @@ class AppRouter extends EventTarget {
69
82
  }
70
83
 
71
84
  private handleRequest(connection: ProtocolConnection, request: JSONRPCRequest): Promise<Result> {
85
+ if (this.isDisposed) {
86
+ return Promise.reject(routerStoppedError());
87
+ }
88
+
72
89
  if (request.method === "app.register") {
73
90
  return Promise.resolve(this.handleRegister(connection, request));
74
91
  }
@@ -134,6 +151,40 @@ class AppRouter extends EventTarget {
134
151
  this.dispatchEvent(appUnregisteredEvent(applicationId));
135
152
  }
136
153
 
154
+ private async waitForRegistration(applicationId: string | undefined, timeout: number): Promise<AppInfo> {
155
+ const controller = new AbortController();
156
+ const { promise, reject, resolve } = Promise.withResolvers<AppInfo>();
157
+ const rejectWait = (error: Error): void => {
158
+ controller.abort();
159
+ reject(error);
160
+ };
161
+ const pendingWait: PendingAppWait = { reject: rejectWait };
162
+ const timeoutId = setTimeout(() => {
163
+ rejectWait(appWaitTimeoutError(timeout));
164
+ }, timeout);
165
+
166
+ this.addEventListener(
167
+ "appRegistered",
168
+ (event) => {
169
+ const info = (event as AppRegisteredEvent).detail;
170
+
171
+ if (applicationId === undefined || info.applicationId === applicationId) {
172
+ resolve(info);
173
+ }
174
+ },
175
+ { signal: controller.signal },
176
+ );
177
+ this.pendingAppWaits.add(pendingWait);
178
+
179
+ try {
180
+ return await promise;
181
+ } finally {
182
+ clearTimeout(timeoutId);
183
+ controller.abort();
184
+ this.pendingAppWaits.delete(pendingWait);
185
+ }
186
+ }
187
+
137
188
  getApps(): AppInfo[] {
138
189
  return this.apps.values().map((app) => app.info).toArray();
139
190
  }
@@ -152,36 +203,40 @@ class AppRouter extends EventTarget {
152
203
  return this.getDefaultApp()?.info.projectRoot;
153
204
  }
154
205
 
155
- waitForApp(timeout: number = AppRouter.defaultWaitTimeout): Promise<AppInfo> {
156
- const defaultApp = this.getDefaultApp();
206
+ dispose(): void {
207
+ if (this.isDisposed) {
208
+ return;
209
+ }
210
+
211
+ this.isDisposed = true;
212
+ const error = routerStoppedError();
157
213
 
158
- if (defaultApp) {
159
- return Promise.resolve(defaultApp.info);
214
+ for (const pendingWait of this.pendingAppWaits) {
215
+ pendingWait.reject(error);
160
216
  }
217
+ }
161
218
 
162
- return new Promise((resolve, reject) => {
163
- const timeoutId = setTimeout(() => {
164
- this.removeEventListener("appRegistered", onRegister);
219
+ waitForApp(applicationId?: string, timeout: number = AppRouter.defaultWaitTimeout): Promise<AppInfo> {
220
+ if (this.isDisposed) {
221
+ return Promise.reject(routerStoppedError());
222
+ }
165
223
 
166
- reject(
167
- new Error(
168
- `Timeout waiting for app registration after ${String(timeout)}ms. ` +
169
- "Make sure your GTKX app is running with 'gtkx dev'.",
170
- ),
171
- );
172
- }, timeout);
224
+ const app = applicationId === undefined ? this.getDefaultApp() : this.apps.get(applicationId);
173
225
 
174
- const onRegister = (event: Event): void => {
175
- clearTimeout(timeoutId);
176
- this.removeEventListener("appRegistered", onRegister);
177
- resolve((event as AppRegisteredEvent).detail);
178
- };
226
+ if (app) {
227
+ return Promise.resolve(app.info);
228
+ }
179
229
 
180
- this.addEventListener("appRegistered", onRegister);
181
- });
230
+ return this.waitForRegistration(applicationId, timeout);
182
231
  }
183
232
 
184
- async sendToApp<T>(applicationId: string | undefined, method: string, params?: RequestParams): Promise<T> {
233
+ async sendToApp<T>(
234
+ applicationId: string | undefined,
235
+ method: string,
236
+ params?: RequestParams,
237
+ waitTimeout?: number,
238
+ ): Promise<T> {
239
+ await this.waitForApp(applicationId, waitTimeout);
185
240
  const app = this.resolveTargetApp(applicationId);
186
241
 
187
242
  try {
package/src/internal.ts CHANGED
@@ -6,7 +6,6 @@ export {
6
6
  widgetNotFoundError,
7
7
  } from "./protocol/errors.js";
8
8
  export {
9
- DEFAULT_SOCKET_PATH,
10
9
  DEFAULT_SUBTREE_DEPTH,
11
10
  MAX_SUBTREE_WIDGETS,
12
11
  type ParamsSchema,
@@ -16,5 +15,9 @@ export {
16
15
  type ServerRequestParams,
17
16
  ServerRequestParamsSchemas,
18
17
  } from "./protocol/schemas.js";
18
+ export {
19
+ MCP_SOCKET_PATH_ENV,
20
+ resolveMcpSocketPath,
21
+ } from "./socket-path.js";
19
22
  export { ProtocolConnection } from "./transport.js";
20
23
  export type { JSONRPCRequest, Result } from "@modelcontextprotocol/sdk/types.js";
@@ -1,5 +1,3 @@
1
- import { tmpdir } from "node:os";
2
- import { join } from "node:path";
3
1
  import { z } from "zod";
4
2
 
5
3
  type SerializedWidget = {
@@ -130,12 +128,6 @@ const ServerRequestParamsSchemas: {
130
128
  "widget.screenshot": screenshotParams,
131
129
  };
132
130
 
133
- const DEFAULT_SOCKET_PATH: string = join(getRuntimeDir(), "gtkx-mcp.sock");
134
-
135
- function getRuntimeDir(): string {
136
- return process.env.XDG_RUNTIME_DIR ?? tmpdir();
137
- }
138
-
139
131
  export {
140
132
  DEFAULT_SUBTREE_DEPTH,
141
133
  MAX_SUBTREE_WIDGETS,
@@ -148,7 +140,6 @@ export {
148
140
  fireEventParams,
149
141
  screenshotParams,
150
142
  ServerRequestParamsSchemas,
151
- DEFAULT_SOCKET_PATH,
152
143
  type SerializedWidget,
153
144
  type SerializedProperty,
154
145
  type AppInfo,
package/src/reference.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { type ApiReference, type ApiSymbol, loadApiReference, resolveGirPath, resolveLibraries } from "@gtkx/codegen";
2
2
  import { loadConfig } from "@gtkx/config";
3
- import { resolveFuture } from "@gtkx/config/internal";
4
3
  import { type McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
5
4
  import { type CallToolResult, ErrorCode, McpError, type ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
6
5
  import { existsSync, statSync } from "node:fs";
@@ -59,7 +58,7 @@ type ResourceServer = Pick<McpServer, "registerResource">;
59
58
 
60
59
  const FRESHNESS_INTERVAL_MS = 2000;
61
60
  const FAILURE_RETRY_MS = 5000;
62
- const CONFIG_EXTENSIONS = ["ts", "mts", "cts", "js", "mjs", "cjs", "json"];
61
+ const CONFIG_EXTENSIONS = ["ts", "mts", "js", "mjs", "json"];
63
62
 
64
63
  const PROJECT_SOURCE_LABELS: Record<ProjectSource, string> = {
65
64
  argument: "requested with `projectRoot`",
@@ -80,8 +79,8 @@ const SYMBOL_KIND = z.enum([
80
79
  ]);
81
80
 
82
81
  const SYMBOL_DESCRIPTION =
83
- "Qualified symbol name (`Gtk.Button`, `Gtk.Orientation`, `GLib.idleAdd`), JSX element name (`GtkButton`), " +
84
- "or bare symbol name when unambiguous (`Button`).";
82
+ "Qualified symbol name (`Adw.Toast`, `Gtk.Orientation`, `GLib.idleAdd`), JSX element name (`AdwToast`), " +
83
+ "or bare symbol name when unambiguous (`Toast`).";
85
84
 
86
85
  const PROJECT_ROOT_DESCRIPTION =
87
86
  "Directory of the GTKX project whose bindings to document, absolute or relative to the working directory. " +
@@ -97,13 +96,13 @@ const listApiShape = {
97
96
  namespace: z
98
97
  .string()
99
98
  .optional()
100
- .describe("Namespace to list (e.g. `Gtk`, `Adw`, `Gio`). Omit for an overview of all namespaces."),
99
+ .describe("Namespace to list (e.g. `Adw`, `Gtk`, `Gio`). Omit for an overview of all namespaces."),
101
100
  };
102
101
 
103
102
  const searchApiShape = {
104
103
  ...projectRootShape,
105
104
  query: z.string().describe("Case-insensitive substring of a symbol name, e.g. `headerbar` or `orientation`."),
106
- namespace: z.string().optional().describe("Restrict matches to one namespace (e.g. `Gtk`)."),
105
+ namespace: z.string().optional().describe("Restrict matches to one namespace (e.g. `Adw`)."),
107
106
  kind: SYMBOL_KIND.optional().describe("Restrict matches to one symbol kind."),
108
107
  limit: z.number().int().min(1).optional().describe("Maximum number of results (default: 20)."),
109
108
  };
@@ -193,17 +192,11 @@ const loadReference = async (requestedRoot: string): Promise<LoadedReference> =>
193
192
  );
194
193
  }
195
194
 
196
- const future = resolveFuture(config.future);
197
- const libraries = resolveLibraries(config.libraries, girPath, future.isAdwaitaDefault);
195
+ const libraries = resolveLibraries(config.libraries);
198
196
 
199
197
  const reference = loadApiReference({
200
198
  libraries,
201
199
  girPath,
202
- isByteArrayTyped: future.isByteArrayTyped,
203
- isValueUnwrapped: future.isValueUnwrapped,
204
- isFinishTrimmed: future.isFinishTrimmed,
205
- isInoutInPlace: future.isInoutInPlace,
206
- isTreeShaken: future.isTreeShaken,
207
200
  });
208
201
 
209
202
  const watched = [watchFile(resolve(root, configFile)), ...reference.girFiles.map((file) => watchFile(file))];
@@ -211,17 +204,13 @@ const loadReference = async (requestedRoot: string): Promise<LoadedReference> =>
211
204
  return { reference, root, watched };
212
205
  };
213
206
 
214
- const markFailed = async (entry: CacheEntry): Promise<void> => {
215
- try {
216
- await entry.pending;
217
- } catch {
218
- entry.failedAt = Date.now();
219
- }
220
- };
221
-
222
207
  const startLoad = (cache: ReferenceCache, root: string): CacheEntry => {
223
208
  const entry: CacheEntry = { pending: loadReference(root), verifiedAt: Date.now(), failedAt: undefined };
224
- void markFailed(entry);
209
+
210
+ void entry.pending.catch(() => {
211
+ entry.failedAt = Date.now();
212
+ });
213
+
225
214
  cache.set(root, entry);
226
215
 
227
216
  return entry;
@@ -259,11 +248,9 @@ const currentReference = async (cache: ReferenceCache, root: string): Promise<Lo
259
248
  return revalidate(cache, root, entry).pending;
260
249
  };
261
250
 
262
- const defaultWorkingDirectory = (): string => process.cwd();
263
-
264
251
  const createReferenceProvider = (options: ReferenceProviderOptions): ReferenceProvider => {
265
252
  const cache: ReferenceCache = new Map();
266
- const getWorkingDirectory = options.getWorkingDirectory ?? defaultWorkingDirectory;
253
+ const getWorkingDirectory = options.getWorkingDirectory ?? (() => process.cwd());
267
254
 
268
255
  const resolve = (projectRoot?: string): ResolvedProject =>
269
256
  resolveProject(getWorkingDirectory(), options.getAppRoot, projectRoot);
@@ -308,23 +295,12 @@ const scopedResult = async (
308
295
  const formatCandidates = (candidates: ApiSymbol[]): string =>
309
296
  candidates.map((candidate) => `- ${candidate.namespace}.${candidate.name} (${candidate.kind})`).join("\n");
310
297
 
311
- const buildSearchOptions = (args: ToolArgs<typeof searchApiShape>): SearchOptions => {
312
- const options: SearchOptions = { query: args.query };
313
-
314
- if (args.namespace !== undefined) {
315
- options.namespace = args.namespace;
316
- }
317
-
318
- if (args.kind !== undefined) {
319
- options.kinds = [args.kind];
320
- }
321
-
322
- if (args.limit !== undefined) {
323
- options.limit = args.limit;
324
- }
325
-
326
- return options;
327
- };
298
+ const buildSearchOptions = (args: ToolArgs<typeof searchApiShape>): SearchOptions => ({
299
+ query: args.query,
300
+ ...(args.namespace !== undefined && { namespace: args.namespace }),
301
+ ...(args.kind !== undefined && { kinds: [args.kind] }),
302
+ ...(args.limit !== undefined && { limit: args.limit }),
303
+ });
328
304
 
329
305
  const listApiResult = (reference: ReferenceApi, namespace: string | undefined): CallToolResult => {
330
306
  if (namespace === undefined) {
@@ -378,8 +354,9 @@ const listApiTool = (provider: ReferenceProvider): Tool =>
378
354
  title: "List API reference",
379
355
  kind: "readOnly",
380
356
  description:
381
- "List the project's generated GTK4 bindings API (`@gtkx/gi` and `@gtkx/jsx`). Without a namespace, " +
382
- "returns every namespace with symbol counts; with a namespace, lists all of its symbols grouped by kind.",
357
+ "List the project's generated bindings API (`@gtkx/gi` and `@gtkx/jsx`), including Adwaita, " +
358
+ "GTK4, and its declared GIR libraries. Without a namespace, returns every namespace with symbol counts; " +
359
+ "with a namespace, lists all of its symbols grouped by kind.",
383
360
  inputSchema: listApiShape,
384
361
  handler: ({ namespace, projectRoot }) =>
385
362
  scopedResult(provider, projectRoot, (reference) => listApiResult(reference, namespace)),
@@ -391,7 +368,7 @@ const searchApiTool = (provider: ReferenceProvider): Tool =>
391
368
  title: "Search API reference",
392
369
  kind: "readOnly",
393
370
  description:
394
- "Search the project's generated GTK4 bindings API by symbol name. Returns matching symbols with " +
371
+ "Search the project's generated bindings API by symbol name. Returns matching symbols with " +
395
372
  "their namespace, kind, and a one-line summary; fetch full pages with `gtkx_get_api_docs`.",
396
373
  inputSchema: searchApiShape,
397
374
  handler: (args) => scopedResult(provider, args.projectRoot, (reference) => searchApiResult(reference, args)),
@@ -403,7 +380,7 @@ const getApiDocsTool = (provider: ReferenceProvider): Tool =>
403
380
  title: "Get API docs",
404
381
  kind: "readOnly",
405
382
  description:
406
- "Get the full reference page for one symbol of the project's generated GTK4 bindings: JSX elements " +
383
+ "Get the full reference page for one symbol of the project's generated bindings: JSX elements " +
407
384
  "(props, signals, methods) or `@gtkx/gi` classes, interfaces, records, enums, callbacks, aliases, " +
408
385
  "functions, and constants.",
409
386
  inputSchema: apiDocsShape,
@@ -431,9 +408,6 @@ const withLoadFallback = async <T>(load: () => Promise<T>, fallback: T): Promise
431
408
  }
432
409
  };
433
410
 
434
- const namesStartingWith = (names: string[], value: string): string[] =>
435
- names.filter((name) => name.toLowerCase().startsWith(value.toLowerCase()));
436
-
437
411
  const completeNames = (
438
412
  provider: ReferenceProvider,
439
413
  value: string,
@@ -442,7 +416,7 @@ const completeNames = (
442
416
  withLoadFallback(async () => {
443
417
  const { reference } = await provider.get();
444
418
 
445
- return namesStartingWith(collect(reference), value);
419
+ return collect(reference).filter((name) => name.toLowerCase().startsWith(value.toLowerCase()));
446
420
  }, []);
447
421
 
448
422
  const namespaceCompleter =
@@ -488,7 +462,7 @@ const registerIndexResource = (server: ResourceServer, provider: ReferenceProvid
488
462
  "gtkx://reference/index",
489
463
  {
490
464
  title: "GTKX API reference index",
491
- description: "Namespaces of the project's generated GTK4 bindings, with symbol and JSX element counts.",
465
+ description: "Namespaces of the project's generated bindings, with symbol and JSX element counts.",
492
466
  mimeType: "text/markdown",
493
467
  },
494
468
  async (uri) => {
@@ -524,7 +498,7 @@ const registerNamespaceResource = (server: ResourceServer, provider: ReferencePr
524
498
  }),
525
499
  {
526
500
  title: "GTKX namespace reference",
527
- description: "All symbols of one namespace of the project's generated GTK4 bindings, grouped by kind.",
501
+ description: "All symbols of one namespace of the project's generated bindings, grouped by kind.",
528
502
  mimeType: "text/markdown",
529
503
  },
530
504
  async (uri, variables) => {
@@ -555,7 +529,7 @@ const registerSymbolResource = (server: ResourceServer, provider: ReferenceProvi
555
529
  {
556
530
  title: "GTKX symbol reference",
557
531
  description:
558
- "Reference page for one symbol of the project's generated GTK4 bindings: a JSX element or a " +
532
+ "Reference page for one symbol of the project's generated bindings: a JSX element or a " +
559
533
  "class, interface, record, enum, callback, alias, function, or constant.",
560
534
  mimeType: "text/markdown",
561
535
  },