@gtkx/mcp 1.0.0-rc.4 → 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/reference.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { type ApiReference, type ApiSymbol, loadApiReference, resolveGirPath, resolveLibraries } from "@gtkx/codegen";
2
2
  import { loadConfig } from "@gtkx/config";
3
3
  import { type McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
4
- import { ErrorCode, McpError, type ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
5
- import { statSync } from "node:fs";
6
- import { resolve } from "node:path";
4
+ import { type CallToolResult, ErrorCode, McpError, type ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
5
+ import { existsSync, statSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
7
  import { z } from "zod";
8
8
  import { defineTool, textContent, textError, type Tool, type ToolArgs } from "./tool.js";
9
9
 
@@ -12,8 +12,26 @@ type ReferenceApi = Pick<
12
12
  "lookup" | "namespaceOverview" | "namespaces" | "overview" | "search" | "symbolNames"
13
13
  >;
14
14
 
15
+ type ProjectSource = "argument" | "workingDirectory" | "app";
16
+
17
+ type ResolvedProject = {
18
+ root: string;
19
+ source: ProjectSource;
20
+ };
21
+
22
+ type ScopedReference = ResolvedProject & {
23
+ reference: ReferenceApi;
24
+ };
25
+
26
+ type ReferenceProviderOptions = {
27
+ getAppRoot: () => string | undefined;
28
+ getWorkingDirectory?: () => string;
29
+ };
30
+
15
31
  type ReferenceProvider = {
16
- get(): Promise<ReferenceApi>;
32
+ get(projectRoot?: string): Promise<ScopedReference>;
33
+ load(project: ResolvedProject): Promise<ScopedReference>;
34
+ resolve(projectRoot?: string): ResolvedProject;
17
35
  };
18
36
 
19
37
  type WatchedFile = {
@@ -24,6 +42,7 @@ type WatchedFile = {
24
42
 
25
43
  type LoadedReference = {
26
44
  reference: ApiReference;
45
+ root: string;
27
46
  watched: WatchedFile[];
28
47
  };
29
48
 
@@ -39,6 +58,13 @@ type ResourceServer = Pick<McpServer, "registerResource">;
39
58
 
40
59
  const FRESHNESS_INTERVAL_MS = 2000;
41
60
  const FAILURE_RETRY_MS = 5000;
61
+ const CONFIG_EXTENSIONS = ["ts", "mts", "cts", "js", "mjs", "cjs", "json"];
62
+
63
+ const PROJECT_SOURCE_LABELS: Record<ProjectSource, string> = {
64
+ argument: "requested with `projectRoot`",
65
+ workingDirectory: "found from the working directory",
66
+ app: "taken from a connected app; pass `projectRoot` to document another project",
67
+ };
42
68
 
43
69
  const SYMBOL_KIND = z.enum([
44
70
  "element",
@@ -56,7 +82,17 @@ const SYMBOL_DESCRIPTION =
56
82
  "Qualified symbol name (`Gtk.Button`, `Gtk.Orientation`, `GLib.idleAdd`), JSX element name (`GtkButton`), " +
57
83
  "or bare symbol name when unambiguous (`Button`).";
58
84
 
85
+ const PROJECT_ROOT_DESCRIPTION =
86
+ "Directory of the GTKX project whose bindings to document, absolute or relative to the working directory. " +
87
+ "Any directory inside the project works; its `gtkx.config.ts` decides the documented libraries. Omit to use " +
88
+ "the project containing the working directory, falling back to a connected app's project.";
89
+
90
+ const projectRootShape = {
91
+ projectRoot: z.string().optional().describe(PROJECT_ROOT_DESCRIPTION),
92
+ };
93
+
59
94
  const listApiShape = {
95
+ ...projectRootShape,
60
96
  namespace: z
61
97
  .string()
62
98
  .optional()
@@ -64,6 +100,7 @@ const listApiShape = {
64
100
  };
65
101
 
66
102
  const searchApiShape = {
103
+ ...projectRootShape,
67
104
  query: z.string().describe("Case-insensitive substring of a symbol name, e.g. `headerbar` or `orientation`."),
68
105
  namespace: z.string().optional().describe("Restrict matches to one namespace (e.g. `Gtk`)."),
69
106
  kind: SYMBOL_KIND.optional().describe("Restrict matches to one symbol kind."),
@@ -71,6 +108,7 @@ const searchApiShape = {
71
108
  };
72
109
 
73
110
  const apiDocsShape = {
111
+ ...projectRootShape,
74
112
  symbol: z.string().describe(SYMBOL_DESCRIPTION),
75
113
  kind: SYMBOL_KIND.optional().describe("Disambiguate when several kinds share the symbol name."),
76
114
  };
@@ -92,13 +130,55 @@ const isFresh = (loaded: LoadedReference): boolean =>
92
130
  return current.mtimeMs === file.mtimeMs && current.size === file.size;
93
131
  });
94
132
 
95
- const loadReference = async (root: string): Promise<LoadedReference> => {
96
- const { config, configFile } = await loadConfig(root);
133
+ const hasConfigFile = (directory: string): boolean =>
134
+ CONFIG_EXTENSIONS.some((extension) => existsSync(join(directory, `gtkx.config.${extension}`)));
135
+
136
+ const findProjectRoot = (start: string): string | undefined => {
137
+ const current = resolve(start);
138
+ const parent = dirname(current);
139
+
140
+ if (hasConfigFile(current)) {
141
+ return current;
142
+ }
143
+
144
+ return parent === current ? undefined : findProjectRoot(parent);
145
+ };
146
+
147
+ const projectAt = (candidate: string, source: ProjectSource): ResolvedProject => ({
148
+ root: findProjectRoot(candidate) ?? resolve(candidate),
149
+ source,
150
+ });
151
+
152
+ const resolveProject = (
153
+ workingDirectory: string,
154
+ getAppRoot: () => string | undefined,
155
+ projectRoot: string | undefined,
156
+ ): ResolvedProject => {
157
+ if (projectRoot !== undefined) {
158
+ return projectAt(projectRoot, "argument");
159
+ }
160
+
161
+ const discovered = findProjectRoot(workingDirectory);
162
+
163
+ if (discovered !== undefined) {
164
+ return { root: discovered, source: "workingDirectory" };
165
+ }
166
+
167
+ const appRoot = getAppRoot();
168
+
169
+ return appRoot === undefined
170
+ ? { root: resolve(workingDirectory), source: "workingDirectory" }
171
+ : projectAt(appRoot, "app");
172
+ };
173
+
174
+ const loadReference = async (requestedRoot: string): Promise<LoadedReference> => {
175
+ const { config, configFile, root } = await loadConfig(requestedRoot);
97
176
 
98
177
  if (config.codegen === false) {
99
178
  throw new Error(
100
179
  `codegen is disabled for the project at ${root}, so there are no generated bindings to document. ` +
101
- "Remove `codegen: false` from gtkx.config.ts to use the API reference.",
180
+ "Remove `codegen: false` from gtkx.config.ts to use the API reference, or point the `projectRoot` " +
181
+ "argument at another project.",
102
182
  );
103
183
  }
104
184
 
@@ -116,7 +196,7 @@ const loadReference = async (root: string): Promise<LoadedReference> => {
116
196
  const reference = loadApiReference({ libraries, girPath });
117
197
  const watched = [watchFile(resolve(root, configFile)), ...reference.girFiles.map((file) => watchFile(file))];
118
198
 
119
- return { reference, watched };
199
+ return { reference, root, watched };
120
200
  };
121
201
 
122
202
  const markFailed = async (entry: CacheEntry): Promise<void> => {
@@ -150,31 +230,67 @@ const revalidate = (cache: ReferenceCache, root: string, entry: CacheEntry): Cac
150
230
  return current === undefined || current === entry ? startLoad(cache, root) : current;
151
231
  };
152
232
 
153
- const currentReference = async (cache: ReferenceCache, root: string): Promise<ReferenceApi> => {
233
+ const currentReference = async (cache: ReferenceCache, root: string): Promise<LoadedReference> => {
154
234
  const entry = resolveEntry(cache, root);
155
235
  const loaded = await entry.pending;
156
236
 
157
237
  if (Date.now() - entry.verifiedAt < FRESHNESS_INTERVAL_MS) {
158
- return loaded.reference;
238
+ return loaded;
159
239
  }
160
240
 
161
241
  if (isFresh(loaded)) {
162
242
  entry.verifiedAt = Date.now();
163
243
 
164
- return loaded.reference;
244
+ return loaded;
165
245
  }
166
246
 
167
- const revalidated = await revalidate(cache, root, entry).pending;
168
-
169
- return revalidated.reference;
247
+ return revalidate(cache, root, entry).pending;
170
248
  };
171
249
 
172
- const createReferenceProvider = (resolveRoot: () => string): ReferenceProvider => {
250
+ const defaultWorkingDirectory = (): string => process.cwd();
251
+
252
+ const createReferenceProvider = (options: ReferenceProviderOptions): ReferenceProvider => {
173
253
  const cache: ReferenceCache = new Map();
254
+ const getWorkingDirectory = options.getWorkingDirectory ?? defaultWorkingDirectory;
174
255
 
175
- return {
176
- get: () => currentReference(cache, resolve(resolveRoot())),
256
+ const resolve = (projectRoot?: string): ResolvedProject =>
257
+ resolveProject(getWorkingDirectory(), options.getAppRoot, projectRoot);
258
+
259
+ const load = async (project: ResolvedProject): Promise<ScopedReference> => {
260
+ const loaded = await currentReference(cache, project.root);
261
+
262
+ return { reference: loaded.reference, root: loaded.root, source: project.source };
177
263
  };
264
+
265
+ const get = (projectRoot?: string): Promise<ScopedReference> => load(resolve(projectRoot));
266
+
267
+ return { get, load, resolve };
268
+ };
269
+
270
+ const projectNote = (project: ResolvedProject): string =>
271
+ `Project: ${project.root} (${PROJECT_SOURCE_LABELS[project.source]})`;
272
+
273
+ const withProjectNote = (result: CallToolResult, note: string): CallToolResult => ({
274
+ ...result,
275
+ content: [...result.content, { type: "text", text: note }],
276
+ });
277
+
278
+ const scopedResult = async (
279
+ provider: ReferenceProvider,
280
+ projectRoot: string | undefined,
281
+ render: (reference: ReferenceApi) => CallToolResult,
282
+ ): Promise<CallToolResult> => {
283
+ const resolved = provider.resolve(projectRoot);
284
+
285
+ try {
286
+ const scoped = await provider.load(resolved);
287
+
288
+ return withProjectNote(render(scoped.reference), projectNote(scoped));
289
+ } catch (error) {
290
+ const message = error instanceof Error ? error.message : String(error);
291
+
292
+ return withProjectNote(textError(message), projectNote(resolved));
293
+ }
178
294
  };
179
295
 
180
296
  const formatCandidates = (candidates: ApiSymbol[]): string =>
@@ -198,6 +314,52 @@ const buildSearchOptions = (args: ToolArgs<typeof searchApiShape>): SearchOption
198
314
  return options;
199
315
  };
200
316
 
317
+ const listApiResult = (reference: ReferenceApi, namespace: string | undefined): CallToolResult => {
318
+ if (namespace === undefined) {
319
+ return textContent(reference.overview());
320
+ }
321
+
322
+ const overview = reference.namespaceOverview(namespace);
323
+
324
+ if (overview === undefined) {
325
+ const names = reference
326
+ .namespaces()
327
+ .map((summary) => summary.name)
328
+ .join(", ");
329
+
330
+ return textError(`Unknown namespace "${namespace}". Available namespaces: ${names}`);
331
+ }
332
+
333
+ return textContent(overview);
334
+ };
335
+
336
+ const searchApiResult = (reference: ReferenceApi, args: ToolArgs<typeof searchApiShape>): CallToolResult => {
337
+ const results = reference.search(buildSearchOptions(args));
338
+
339
+ if (results.length === 0) {
340
+ return textContent(`No symbols matched "${args.query}". Try a shorter substring or \`gtkx_list_api\`.`);
341
+ }
342
+
343
+ return textContent(JSON.stringify(results, null, 2));
344
+ };
345
+
346
+ const apiDocsResult = (reference: ReferenceApi, args: ToolArgs<typeof apiDocsShape>): CallToolResult => {
347
+ const result = reference.lookup(args.symbol, args.kind);
348
+
349
+ if (result.outcome === "notFound") {
350
+ return textError(`No symbol named "${args.symbol}". Use \`gtkx_search_api\` to find the right name.`);
351
+ }
352
+
353
+ if (result.outcome === "ambiguous") {
354
+ return textError(
355
+ `"${args.symbol}" matches several symbols. Pass a qualified name or a kind:\n` +
356
+ formatCandidates(result.candidates),
357
+ );
358
+ }
359
+
360
+ return textContent(result.markdown);
361
+ };
362
+
201
363
  const listApiTool = (provider: ReferenceProvider): Tool =>
202
364
  defineTool({
203
365
  name: "gtkx_list_api",
@@ -207,26 +369,8 @@ const listApiTool = (provider: ReferenceProvider): Tool =>
207
369
  "List the project's generated GTK4 bindings API (`@gtkx/gi` and `@gtkx/jsx`). Without a namespace, " +
208
370
  "returns every namespace with symbol counts; with a namespace, lists all of its symbols grouped by kind.",
209
371
  inputSchema: listApiShape,
210
- handler: async ({ namespace }) => {
211
- const reference = await provider.get();
212
-
213
- if (namespace === undefined) {
214
- return textContent(reference.overview());
215
- }
216
-
217
- const overview = reference.namespaceOverview(namespace);
218
-
219
- if (overview === undefined) {
220
- const names = reference
221
- .namespaces()
222
- .map((summary) => summary.name)
223
- .join(", ");
224
-
225
- return textError(`Unknown namespace "${namespace}". Available namespaces: ${names}`);
226
- }
227
-
228
- return textContent(overview);
229
- },
372
+ handler: ({ namespace, projectRoot }) =>
373
+ scopedResult(provider, projectRoot, (reference) => listApiResult(reference, namespace)),
230
374
  });
231
375
 
232
376
  const searchApiTool = (provider: ReferenceProvider): Tool =>
@@ -238,16 +382,7 @@ const searchApiTool = (provider: ReferenceProvider): Tool =>
238
382
  "Search the project's generated GTK4 bindings API by symbol name. Returns matching symbols with " +
239
383
  "their namespace, kind, and a one-line summary; fetch full pages with `gtkx_get_api_docs`.",
240
384
  inputSchema: searchApiShape,
241
- handler: async (args) => {
242
- const reference = await provider.get();
243
- const results = reference.search(buildSearchOptions(args));
244
-
245
- if (results.length === 0) {
246
- return textContent(`No symbols matched "${args.query}". Try a shorter substring or \`gtkx_list_api\`.`);
247
- }
248
-
249
- return textContent(JSON.stringify(results, null, 2));
250
- },
385
+ handler: (args) => scopedResult(provider, args.projectRoot, (reference) => searchApiResult(reference, args)),
251
386
  });
252
387
 
253
388
  const getApiDocsTool = (provider: ReferenceProvider): Tool =>
@@ -260,23 +395,7 @@ const getApiDocsTool = (provider: ReferenceProvider): Tool =>
260
395
  "(props, signals, methods) or `@gtkx/gi` classes, interfaces, records, enums, callbacks, aliases, " +
261
396
  "functions, and constants.",
262
397
  inputSchema: apiDocsShape,
263
- handler: async ({ symbol, kind }) => {
264
- const reference = await provider.get();
265
- const result = reference.lookup(symbol, kind);
266
-
267
- if (result.outcome === "notFound") {
268
- return textError(`No symbol named "${symbol}". Use \`gtkx_search_api\` to find the right name.`);
269
- }
270
-
271
- if (result.outcome === "ambiguous") {
272
- return textError(
273
- `"${symbol}" matches several symbols. Pass a qualified name or a kind:\n` +
274
- formatCandidates(result.candidates),
275
- );
276
- }
277
-
278
- return textContent(result.markdown);
279
- },
398
+ handler: (args) => scopedResult(provider, args.projectRoot, (reference) => apiDocsResult(reference, args)),
280
399
  });
281
400
 
282
401
  const buildReferenceTools = (provider: ReferenceProvider): Tool[] => [
@@ -300,28 +419,31 @@ const withLoadFallback = async <T>(load: () => Promise<T>, fallback: T): Promise
300
419
  }
301
420
  };
302
421
 
422
+ const namesStartingWith = (names: string[], value: string): string[] =>
423
+ names.filter((name) => name.toLowerCase().startsWith(value.toLowerCase()));
424
+
425
+ const completeNames = (
426
+ provider: ReferenceProvider,
427
+ value: string,
428
+ collect: (reference: ReferenceApi) => string[],
429
+ ): Promise<string[]> =>
430
+ withLoadFallback(async () => {
431
+ const { reference } = await provider.get();
432
+
433
+ return namesStartingWith(collect(reference), value);
434
+ }, []);
435
+
303
436
  const namespaceCompleter =
304
437
  (provider: ReferenceProvider) =>
305
438
  (value: string): Promise<string[]> =>
306
- withLoadFallback(async () => {
307
- const reference = await provider.get();
308
-
309
- return reference
310
- .namespaces()
311
- .map((summary) => summary.name)
312
- .filter((name) => name.toLowerCase().startsWith(value.toLowerCase()));
313
- }, []);
439
+ completeNames(provider, value, (reference) => reference.namespaces().map((summary) => summary.name));
314
440
 
315
- const completeSymbol = async (provider: ReferenceProvider, namespace: string, value: string): Promise<string[]> => {
441
+ const completeSymbol = (provider: ReferenceProvider, namespace: string, value: string): Promise<string[]> => {
316
442
  if (namespace.length === 0) {
317
- return [];
443
+ return Promise.resolve([]);
318
444
  }
319
445
 
320
- return withLoadFallback(async () => {
321
- const reference = await provider.get();
322
-
323
- return reference.symbolNames(namespace).filter((name) => name.toLowerCase().startsWith(value.toLowerCase()));
324
- }, []);
446
+ return completeNames(provider, value, (reference) => reference.symbolNames(namespace));
325
447
  };
326
448
 
327
449
  const resourceNotFound = (message: string): McpError => new McpError(ErrorCode.InvalidParams, message);
@@ -332,7 +454,7 @@ const symbolPage = async (
332
454
  namespace: string,
333
455
  symbol: string,
334
456
  ): Promise<ReadResourceResult> => {
335
- const reference = await provider.get();
457
+ const { reference } = await provider.get();
336
458
  const result = reference.lookup(`${namespace}.${symbol}`);
337
459
 
338
460
  if (result.outcome === "page") {
@@ -358,7 +480,7 @@ const registerIndexResource = (server: ResourceServer, provider: ReferenceProvid
358
480
  mimeType: "text/markdown",
359
481
  },
360
482
  async (uri) => {
361
- const reference = await provider.get();
483
+ const { reference } = await provider.get();
362
484
 
363
485
  return markdownResource(uri, reference.overview());
364
486
  },
@@ -372,7 +494,7 @@ const registerNamespaceResource = (server: ResourceServer, provider: ReferencePr
372
494
  list: () =>
373
495
  withLoadFallback(
374
496
  async () => {
375
- const reference = await provider.get();
497
+ const { reference } = await provider.get();
376
498
 
377
499
  return {
378
500
  resources: reference.namespaces().map((summary) => ({
@@ -395,7 +517,7 @@ const registerNamespaceResource = (server: ResourceServer, provider: ReferencePr
395
517
  },
396
518
  async (uri, variables) => {
397
519
  const namespace = variableValue(variables.namespace);
398
- const reference = await provider.get();
520
+ const { reference } = await provider.get();
399
521
  const overview = reference.namespaceOverview(namespace);
400
522
 
401
523
  if (overview === undefined) {
@@ -442,4 +564,5 @@ export {
442
564
  registerReferenceResources,
443
565
  type ReferenceApi,
444
566
  type ReferenceProvider,
567
+ type ResolvedProject,
445
568
  };