@gtkx/mcp 1.4.0 → 1.6.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 (55) hide show
  1. package/README.md +1 -1
  2. package/bin/gtkx-mcp.js +2 -2
  3. package/dist/app-router.d.ts +3 -5
  4. package/dist/app-router.d.ts.map +1 -1
  5. package/dist/app-router.js +21 -34
  6. package/dist/app-router.js.map +1 -1
  7. package/dist/connection-registry.d.ts +3 -4
  8. package/dist/connection-registry.d.ts.map +1 -1
  9. package/dist/connection-registry.js +5 -16
  10. package/dist/connection-registry.js.map +1 -1
  11. package/dist/internal.d.ts +3 -2
  12. package/dist/internal.d.ts.map +1 -1
  13. package/dist/internal.js +1 -1
  14. package/dist/internal.js.map +1 -1
  15. package/dist/protocol/errors.d.ts +6 -7
  16. package/dist/protocol/errors.d.ts.map +1 -1
  17. package/dist/protocol/errors.js +12 -8
  18. package/dist/protocol/errors.js.map +1 -1
  19. package/dist/protocol/schemas.d.ts +1 -19
  20. package/dist/protocol/schemas.d.ts.map +1 -1
  21. package/dist/protocol/schemas.js +1 -16
  22. package/dist/protocol/schemas.js.map +1 -1
  23. package/dist/reference.d.ts.map +1 -1
  24. package/dist/reference.js +8 -5
  25. package/dist/reference.js.map +1 -1
  26. package/dist/server.d.ts +8 -2
  27. package/dist/server.d.ts.map +1 -1
  28. package/dist/server.js +79 -18
  29. package/dist/server.js.map +1 -1
  30. package/dist/socket-server.js +1 -1
  31. package/dist/socket-server.js.map +1 -1
  32. package/dist/tool-filter.d.ts +5 -0
  33. package/dist/tool-filter.d.ts.map +1 -0
  34. package/dist/tool-filter.js +38 -0
  35. package/dist/tool-filter.js.map +1 -0
  36. package/dist/tool.d.ts +1 -0
  37. package/dist/tool.d.ts.map +1 -1
  38. package/dist/tool.js +1 -1
  39. package/dist/tool.js.map +1 -1
  40. package/dist/transport.d.ts +18 -39
  41. package/dist/transport.d.ts.map +1 -1
  42. package/dist/transport.js +78 -127
  43. package/dist/transport.js.map +1 -1
  44. package/package.json +9 -5
  45. package/src/app-router.ts +34 -44
  46. package/src/connection-registry.ts +6 -22
  47. package/src/internal.ts +2 -3
  48. package/src/protocol/errors.ts +20 -10
  49. package/src/protocol/schemas.ts +0 -41
  50. package/src/reference.ts +8 -5
  51. package/src/server.ts +116 -19
  52. package/src/socket-server.ts +1 -1
  53. package/src/tool-filter.ts +54 -0
  54. package/src/tool.ts +2 -1
  55. package/src/transport.ts +106 -171
package/src/app-router.ts CHANGED
@@ -1,19 +1,18 @@
1
+ import { type JSONRPCRequest, McpError, type Result } from "@modelcontextprotocol/sdk/types.js";
2
+ import type { AppConnections, ConnectionEvent, ProtocolConnection, RequestParams } from "./transport.js";
1
3
  import {
2
4
  appNotFoundError,
5
+ CONNECTION_CLOSED_CODE,
3
6
  connectionWriteFailedError,
4
7
  invalidRequestError,
5
8
  methodNotFoundError,
6
9
  noAppConnectedError,
7
10
  type ProtocolError,
11
+ protocolErrorFrom,
12
+ REQUEST_TIMEOUT_CODE,
13
+ requestTimeoutError,
8
14
  } from "./protocol/errors.js";
9
- import { type AppInfo, RegisterParamsSchema, type Request, type Response } from "./protocol/schemas.js";
10
- import {
11
- type AppConnections,
12
- ConnectionClosedError,
13
- type ConnectionEvent,
14
- type ConnectionRequestEvent,
15
- type ProtocolConnection,
16
- } from "./transport.js";
15
+ import { type AppInfo, RegisterParamsSchema } from "./protocol/schemas.js";
17
16
 
18
17
  type AppRegisteredEvent = CustomEvent<AppInfo>;
19
18
  type AppUnregisteredEvent = CustomEvent<string>;
@@ -48,11 +47,7 @@ class AppRouter extends EventTarget {
48
47
  super();
49
48
  this.connections = connections;
50
49
  this.requestTimeout = options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT_MS;
51
-
52
- this.connections.addEventListener("request", (event) => {
53
- const { connection, request } = (event as ConnectionRequestEvent).detail;
54
- this.handleRequest(connection, request);
55
- });
50
+ this.connections.onRequest = (connection, request) => this.handleRequest(connection, request);
56
51
 
57
52
  this.connections.addEventListener("disconnection", (event) => {
58
53
  this.removeApp((event as ConnectionEvent).detail);
@@ -73,23 +68,25 @@ class AppRouter extends EventTarget {
73
68
  throw noAppConnectedError();
74
69
  }
75
70
 
76
- private handleRequest(connection: ProtocolConnection, request: Request): void {
71
+ private handleRequest(connection: ProtocolConnection, request: JSONRPCRequest): Promise<Result> {
77
72
  if (request.method === "app.register") {
78
- this.handleRegister(connection, request);
79
- } else if (request.method === "app.unregister") {
80
- this.handleUnregister(connection, request);
81
- } else {
82
- this.sendError(connection, request, methodNotFoundError(request.method));
73
+ return Promise.resolve(this.handleRegister(connection, request));
74
+ }
75
+
76
+ if (request.method === "app.unregister") {
77
+ this.removeApp(connection);
78
+
79
+ return Promise.resolve({ success: true });
83
80
  }
81
+
82
+ return Promise.reject(methodNotFoundError(request.method));
84
83
  }
85
84
 
86
- private handleRegister(connection: ProtocolConnection, request: Request): void {
85
+ private handleRegister(connection: ProtocolConnection, request: JSONRPCRequest): Result {
87
86
  const parseResult = RegisterParamsSchema.safeParse(request.params);
88
87
 
89
88
  if (!parseResult.success) {
90
- this.sendError(connection, request, invalidRequestError(parseResult.error.message));
91
-
92
- return;
89
+ throw invalidRequestError(parseResult.error.message);
93
90
  }
94
91
 
95
92
  const params = parseResult.data;
@@ -102,29 +99,23 @@ class AppRouter extends EventTarget {
102
99
 
103
100
  this.apps.set(params.applicationId, { info: appInfo, connection });
104
101
  this.connectionToApp.set(connection.id, params.applicationId);
105
- this.acknowledge(connection, request);
106
102
  this.dispatchEvent(appRegisteredEvent(appInfo));
107
- }
108
103
 
109
- private handleUnregister(connection: ProtocolConnection, request: Request): void {
110
- this.removeApp(connection);
111
- this.acknowledge(connection, request);
104
+ return { success: true };
112
105
  }
113
106
 
114
- private acknowledge(connection: ProtocolConnection, request: Request): void {
115
- const response: Response = {
116
- id: request.id,
117
- result: { success: true },
118
- };
107
+ private toAppError(app: RegisteredApp, error: McpError): ProtocolError {
108
+ if (error.code === CONNECTION_CLOSED_CODE) {
109
+ this.removeApp(app.connection);
119
110
 
120
- this.connections.send(connection.id, response);
121
- }
111
+ return connectionWriteFailedError(app.info.applicationId);
112
+ }
122
113
 
123
- private sendError(connection: ProtocolConnection, request: Request, error: ProtocolError): void {
124
- this.connections.send(connection.id, {
125
- id: request.id,
126
- error: error.toErrorObject(),
127
- });
114
+ if (error.code === REQUEST_TIMEOUT_CODE) {
115
+ return requestTimeoutError(this.requestTimeout);
116
+ }
117
+
118
+ return protocolErrorFrom(error);
128
119
  }
129
120
 
130
121
  private removeApp(connection: ProtocolConnection): void {
@@ -190,15 +181,14 @@ class AppRouter extends EventTarget {
190
181
  });
191
182
  }
192
183
 
193
- async sendToApp<T>(applicationId: string | undefined, method: string, params?: unknown): Promise<T> {
184
+ async sendToApp<T>(applicationId: string | undefined, method: string, params?: RequestParams): Promise<T> {
194
185
  const app = this.resolveTargetApp(applicationId);
195
186
 
196
187
  try {
197
188
  return await app.connection.send<T>(method, params, this.requestTimeout);
198
189
  } catch (error) {
199
- if (error instanceof ConnectionClosedError) {
200
- this.removeApp(app.connection);
201
- throw connectionWriteFailedError(app.info.applicationId);
190
+ if (error instanceof McpError) {
191
+ throw this.toAppError(app, error);
202
192
  }
203
193
 
204
194
  throw error;
@@ -1,10 +1,10 @@
1
1
  import type { Socket } from "node:net";
2
- import type { Message } from "./protocol/schemas.js";
2
+ import { methodNotFoundError } from "./protocol/errors.js";
3
3
  import {
4
4
  type AppConnections,
5
5
  connectionDisconnectionEvent,
6
6
  connectionErrorEvent,
7
- connectionRequestEvent,
7
+ type ConnectionRequestHandler,
8
8
  ProtocolConnection,
9
9
  } from "./transport.js";
10
10
 
@@ -12,6 +12,8 @@ class ConnectionRegistry extends EventTarget implements AppConnections {
12
12
  private connections: Map<string, ProtocolConnection> = new Map();
13
13
  private sockets: Map<string, Socket> = new Map();
14
14
 
15
+ onRequest: ConnectionRequestHandler = (_connection, request) => Promise.reject(methodNotFoundError(request.method));
16
+
15
17
  register(socket: Socket): ProtocolConnection {
16
18
  const connection = ProtocolConnection.fromSocket(socket, {
17
19
  onClose: () => {
@@ -24,30 +26,12 @@ class ConnectionRegistry extends EventTarget implements AppConnections {
24
26
 
25
27
  this.connections.set(connection.id, connection);
26
28
  this.sockets.set(connection.id, socket);
27
- connection.on("request", (request) => this.dispatchEvent(connectionRequestEvent(connection, request)));
28
-
29
- connection.on("invalid", ({ id: badId, error }) => {
30
- connection.write({ id: badId, error: error.toErrorObject() });
31
- });
29
+ connection.fallbackRequestHandler = (request) => this.onRequest(connection, request);
32
30
 
33
31
  return connection;
34
32
  }
35
33
 
36
- send(connectionId: string, message: Message): void {
37
- const connection = this.connections.get(connectionId);
38
-
39
- if (!connection) {
40
- return;
41
- }
42
-
43
- connection.write(message);
44
- }
45
-
46
- dispose(reason: string): void {
47
- for (const connection of this.connections.values()) {
48
- connection.rejectPending(new Error(reason));
49
- }
50
-
34
+ dispose(): void {
51
35
  for (const socket of this.sockets.values()) {
52
36
  socket.destroy();
53
37
  }
package/src/internal.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  export {
2
- ErrorCode,
3
2
  invalidRequestError,
3
+ isConnectionClosedError,
4
4
  methodNotFoundError,
5
- ProtocolError,
6
5
  propertyNotFoundError,
7
6
  widgetNotFoundError,
8
7
  } from "./protocol/errors.js";
@@ -11,7 +10,6 @@ export {
11
10
  DEFAULT_SUBTREE_DEPTH,
12
11
  MAX_SUBTREE_WIDGETS,
13
12
  type ParamsSchema,
14
- type Request,
15
13
  type SerializedProperty,
16
14
  type SerializedWidget,
17
15
  type ServerInitiatedMethod,
@@ -19,3 +17,4 @@ export {
19
17
  ServerRequestParamsSchemas,
20
18
  } from "./protocol/schemas.js";
21
19
  export { ProtocolConnection } from "./transport.js";
20
+ export type { JSONRPCRequest, Result } from "@modelcontextprotocol/sdk/types.js";
@@ -1,3 +1,5 @@
1
+ import { McpError, ErrorCode as SdkErrorCode } from "@modelcontextprotocol/sdk/types.js";
2
+
1
3
  type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
2
4
 
3
5
  const ErrorCode = {
@@ -12,6 +14,9 @@ const ErrorCode = {
12
14
  PROPERTY_NOT_FOUND: 1008,
13
15
  } as const;
14
16
 
17
+ const CONNECTION_CLOSED_CODE: number = SdkErrorCode.ConnectionClosed;
18
+ const REQUEST_TIMEOUT_CODE: number = SdkErrorCode.RequestTimeout;
19
+
15
20
  function isErrorCode(code: number): code is ErrorCode {
16
21
  return (Object.values(ErrorCode) as number[]).includes(code);
17
22
  }
@@ -64,6 +69,17 @@ function methodNotFoundError(method: string): ProtocolError {
64
69
  return new ProtocolError(ErrorCode.METHOD_NOT_FOUND, `Method '${method}' not found`, { method });
65
70
  }
66
71
 
72
+ function isConnectionClosedError(value: unknown): boolean {
73
+ return value instanceof McpError && value.code === CONNECTION_CLOSED_CODE;
74
+ }
75
+
76
+ function protocolErrorFrom(error: McpError): ProtocolError {
77
+ const prefix = `MCP error ${String(error.code)}: `;
78
+ const message = error.message.startsWith(prefix) ? error.message.slice(prefix.length) : error.message;
79
+
80
+ return new ProtocolError(isErrorCode(error.code) ? error.code : ErrorCode.INTERNAL_ERROR, message, error.data);
81
+ }
82
+
67
83
  class ProtocolError extends Error {
68
84
  code: ErrorCode;
69
85
  data?: unknown;
@@ -74,19 +90,12 @@ class ProtocolError extends Error {
74
90
  this.data = data;
75
91
  this.name = "ProtocolError";
76
92
  }
77
-
78
- toErrorObject(): { code: number; message: string; data?: unknown } {
79
- return {
80
- code: this.code,
81
- message: this.message,
82
- ...(this.data !== undefined && { data: this.data }),
83
- };
84
- }
85
93
  }
86
94
 
87
95
  export {
88
- ErrorCode,
89
- isErrorCode,
96
+ CONNECTION_CLOSED_CODE,
97
+ isConnectionClosedError,
98
+ REQUEST_TIMEOUT_CODE,
90
99
  noAppConnectedError,
91
100
  appNotFoundError,
92
101
  connectionWriteFailedError,
@@ -96,4 +105,5 @@ export {
96
105
  invalidRequestError,
97
106
  methodNotFoundError,
98
107
  ProtocolError,
108
+ protocolErrorFrom,
99
109
  };
@@ -2,9 +2,6 @@ import { tmpdir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
 
5
- type Request = z.infer<typeof RequestSchema>;
6
- type Response = z.infer<typeof ResponseSchema>;
7
-
8
5
  type SerializedWidget = {
9
6
  id: string;
10
7
  type: string;
@@ -37,39 +34,6 @@ type ServerRequestParams<Method extends keyof typeof ServerRequestParamsSchemas>
37
34
 
38
35
  type ParamsSchema<Output> = z.ZodType<Output>;
39
36
  type ServerInitiatedMethod = keyof typeof ServerRequestParamsSchemas;
40
- type Message = Request | Response;
41
-
42
- const RequestSchema: z.ZodObject<
43
- {
44
- id: z.ZodString;
45
- method: z.ZodString;
46
- params: z.ZodOptional<z.ZodUnknown>;
47
- }
48
- > = z.object({
49
- id: z.string(),
50
- method: z.string(),
51
- params: z.unknown().optional(),
52
- });
53
-
54
- const ErrorSchema: z.ZodObject<
55
- { code: z.ZodNumber; message: z.ZodString; data: z.ZodOptional<z.ZodUnknown> }
56
- > = z.object({
57
- code: z.number(),
58
- message: z.string(),
59
- data: z.unknown().optional(),
60
- });
61
-
62
- const ResponseSchema: z.ZodObject<
63
- {
64
- id: z.ZodString;
65
- result: z.ZodOptional<z.ZodUnknown>;
66
- error: z.ZodOptional<typeof ErrorSchema>;
67
- }
68
- > = z.object({
69
- id: z.string(),
70
- result: z.unknown().optional(),
71
- error: ErrorSchema.optional(),
72
- });
73
37
 
74
38
  const RegisterParamsSchema: z.ZodObject<
75
39
  {
@@ -175,8 +139,6 @@ function getRuntimeDir(): string {
175
139
  export {
176
140
  DEFAULT_SUBTREE_DEPTH,
177
141
  MAX_SUBTREE_WIDGETS,
178
- RequestSchema,
179
- ResponseSchema,
180
142
  RegisterParamsSchema,
181
143
  widgetIdParams,
182
144
  widgetPropsParams,
@@ -187,13 +149,10 @@ export {
187
149
  screenshotParams,
188
150
  ServerRequestParamsSchemas,
189
151
  DEFAULT_SOCKET_PATH,
190
- type Request,
191
- type Response,
192
152
  type SerializedWidget,
193
153
  type SerializedProperty,
194
154
  type AppInfo,
195
155
  type ServerRequestParams,
196
156
  type ParamsSchema,
197
157
  type ServerInitiatedMethod,
198
- type Message,
199
158
  };
package/src/reference.ts CHANGED
@@ -1,5 +1,6 @@
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";
3
4
  import { type McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
4
5
  import { type CallToolResult, ErrorCode, McpError, type ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
5
6
  import { existsSync, statSync } from "node:fs";
@@ -192,15 +193,17 @@ const loadReference = async (requestedRoot: string): Promise<LoadedReference> =>
192
193
  );
193
194
  }
194
195
 
195
- const libraries = resolveLibraries(config.libraries, girPath);
196
+ const future = resolveFuture(config.future);
197
+ const libraries = resolveLibraries(config.libraries, girPath, future.isAdwaitaDefault);
196
198
 
197
199
  const reference = loadApiReference({
198
200
  libraries,
199
201
  girPath,
200
- isByteArrayTyped: config.future?.v2ByteArrays === true,
201
- isValueUnwrapped: config.future?.v2ValueReturns === true,
202
- isFinishTrimmed: config.future?.v2FinishResults === true,
203
- isInoutInPlace: config.future?.v2InoutReturns === true,
202
+ isByteArrayTyped: future.isByteArrayTyped,
203
+ isValueUnwrapped: future.isValueUnwrapped,
204
+ isFinishTrimmed: future.isFinishTrimmed,
205
+ isInoutInPlace: future.isInoutInPlace,
206
+ isTreeShaken: future.isTreeShaken,
204
207
  });
205
208
 
206
209
  const watched = [watchFile(resolve(root, configFile)), ...reference.girFiles.map((file) => watchFile(file))];
package/src/server.ts CHANGED
@@ -1,6 +1,10 @@
1
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ import { loadConfig } from "@gtkx/config";
3
+ import { type McpSettings, resolveMcpSettings } from "@gtkx/config/internal";
1
4
  import { createLogger, installGracefulShutdown, type Logger } from "@gtkx/utils";
2
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { parseArgs } from "node:util";
4
8
  import { z } from "zod";
5
9
  import type { ConnectionErrorEvent } from "./transport.js";
6
10
  import packageManifest from "../package.json" with { type: "json" };
@@ -26,11 +30,19 @@ import {
26
30
  registerReferenceResources,
27
31
  } from "./reference.js";
28
32
  import { SocketServer } from "./socket-server.js";
29
- import { defineTool, imageContent, registerTool, textContent, type Tool } from "./tool.js";
33
+ import { selectTools } from "./tool-filter.js";
34
+ import { defineTool, imageContent, registerTool, textContent, textError, type Tool } from "./tool.js";
30
35
 
31
36
  type CreateMcpServerOptions = {
32
37
  socketPath?: string;
33
38
  version: string;
39
+ settings?: McpSettings;
40
+ };
41
+
42
+ type ServerOptions = {
43
+ cwd?: string;
44
+ tools?: string[];
45
+ isReadOnly?: boolean;
34
46
  };
35
47
 
36
48
  type McpServerHandle = {
@@ -51,6 +63,18 @@ type AppWithWindows = AppInfo & { windows?: AppWindow[] };
51
63
 
52
64
  const { version } = packageManifest;
53
65
  const log: Logger = createLogger("mcp");
66
+ const DEFAULT_SETTINGS: McpSettings = { tools: [], isReadOnly: false };
67
+
68
+ const INSTRUCTIONS =
69
+ "The widget tools drive a GTKX app running under `gtkx dev`: they read " +
70
+ "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 " +
72
+ "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" +
75
+ "Widget IDs are valid only while the widget is mounted. After a dialog closes, a list re-renders, or " +
76
+ "fast refresh patches a component, re-read the tree or re-run the query instead of reusing an ID.";
77
+
54
78
  const APPLICATION_ID_DESCRIPTION = "Application ID to query. If not specified, uses the first connected app.";
55
79
 
56
80
  const WIDGET_ID_DESCRIPTION =
@@ -137,6 +161,14 @@ const screenshotShape = {
137
161
  "Absolute path to write the PNG to on the app's machine. If set, the screenshot is saved there " +
138
162
  "in addition to being returned.",
139
163
  }),
164
+ returnImage: z
165
+ .boolean()
166
+ .optional()
167
+ .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.",
171
+ ),
140
172
  };
141
173
 
142
174
  function describeParams<Shape extends Record<string, z.ZodType>>(
@@ -202,6 +234,28 @@ const listAppsTool = (appRouter: AppRouter): Tool =>
202
234
  },
203
235
  });
204
236
 
237
+ const screenshotResult = (
238
+ result: { data: string; mimeType: string; savedPath?: string },
239
+ shouldReturnImage: boolean,
240
+ ): CallToolResult => {
241
+ if (result.savedPath === undefined) {
242
+ return shouldReturnImage
243
+ ? imageContent(result.data, result.mimeType)
244
+ : textError(
245
+ "Nothing to return: `returnImage` was false and no `path` was given, so the screenshot was " +
246
+ "neither saved nor returned. Pass `path` to save it, or leave `returnImage` unset.",
247
+ );
248
+ }
249
+
250
+ const saved = { type: "text", text: `Screenshot saved to ${result.savedPath}` } as const;
251
+
252
+ if (!shouldReturnImage) {
253
+ return { content: [saved] };
254
+ }
255
+
256
+ return { content: [saved, { type: "image", data: result.data, mimeType: result.mimeType }] };
257
+ };
258
+
205
259
  const screenshotTool = (appRouter: AppRouter): Tool =>
206
260
  defineTool({
207
261
  name: "gtkx_take_screenshot",
@@ -212,23 +266,14 @@ const screenshotTool = (appRouter: AppRouter): Tool =>
212
266
  "the PNG to `path` on the app's machine. You can't target widgets from a screenshot; use " +
213
267
  "`gtkx_get_widget_tree` to find widget IDs for interaction.",
214
268
  inputSchema: screenshotShape,
215
- handler: async ({ applicationId, ...params }) => {
269
+ handler: async ({ applicationId, returnImage, ...params }) => {
216
270
  const result = await appRouter.sendToApp<{ data: string; mimeType: string; savedPath?: string }>(
217
271
  applicationId,
218
272
  "widget.screenshot",
219
273
  params,
220
274
  );
221
275
 
222
- if (result.savedPath) {
223
- return {
224
- content: [
225
- { type: "text", text: `Screenshot saved to ${result.savedPath}` },
226
- { type: "image", data: result.data, mimeType: result.mimeType },
227
- ],
228
- };
229
- }
230
-
231
- return imageContent(result.data, result.mimeType);
276
+ return screenshotResult(result, returnImage !== false);
232
277
  },
233
278
  });
234
279
 
@@ -348,8 +393,19 @@ function buildTools(appRouter: AppRouter): Tool[] {
348
393
  return [...buildInspectionTools(appRouter), ...buildInteractionTools(appRouter)];
349
394
  }
350
395
 
351
- const registerTools = (mcpServer: McpServer, appRouter: AppRouter, provider: ReferenceProvider): void => {
352
- for (const tool of [...buildTools(appRouter), ...buildReferenceTools(provider)]) {
396
+ const registerTools = (
397
+ mcpServer: McpServer,
398
+ appRouter: AppRouter,
399
+ provider: ReferenceProvider,
400
+ settings: McpSettings,
401
+ ): void => {
402
+ const tools = selectTools([...buildTools(appRouter), ...buildReferenceTools(provider)], settings);
403
+
404
+ if (tools.length === 0) {
405
+ log.warn("no tools matched the configured filter; the server is registering none");
406
+ }
407
+
408
+ for (const tool of tools) {
353
409
  registerTool(mcpServer, tool);
354
410
  }
355
411
  };
@@ -402,16 +458,57 @@ const createMcpServer = (options: CreateMcpServerOptions): McpServerHandle => {
402
458
  log.info(`app unregistered: ${(event as AppUnregisteredEvent).detail}`);
403
459
  });
404
460
 
405
- const mcpServer = new McpServer({ name: "gtkx-mcp", version: options.version });
461
+ const mcpServer = new McpServer({ name: "gtkx-mcp", version: options.version }, { instructions: INSTRUCTIONS });
406
462
  const referenceProvider = createReferenceProvider({ getAppRoot: () => appRouter.getProjectRoot() });
407
- registerTools(mcpServer, appRouter, referenceProvider);
463
+ registerTools(mcpServer, appRouter, referenceProvider, options.settings ?? DEFAULT_SETTINGS);
408
464
  registerReferenceResources(mcpServer, referenceProvider);
409
465
 
410
466
  return createServerHandle(socketServer, mcpServer, socketPath);
411
467
  };
412
468
 
413
- async function main(): Promise<void> {
414
- const server = createMcpServer({ version });
469
+ const configuredSettings = async (cwd: string): Promise<McpSettings> => {
470
+ try {
471
+ const { config } = await loadConfig(cwd);
472
+
473
+ return resolveMcpSettings(config);
474
+ } catch {
475
+ return DEFAULT_SETTINGS;
476
+ }
477
+ };
478
+
479
+ const resolveSettings = async (options: ServerOptions): Promise<McpSettings> => {
480
+ const configured = await configuredSettings(options.cwd ?? process.cwd());
481
+
482
+ return {
483
+ tools: options.tools ?? configured.tools,
484
+ isReadOnly: options.isReadOnly ?? configured.isReadOnly,
485
+ };
486
+ };
487
+
488
+ const splitPatterns = (values: string[]): string[] =>
489
+ values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter((value) => value.length > 0);
490
+
491
+ const parseServerArgs = (argv: string[]): ServerOptions => {
492
+ const { values } = parseArgs({
493
+ args: argv,
494
+ options: {
495
+ tools: { type: "string", multiple: true },
496
+ "read-only": { type: "boolean" },
497
+ },
498
+ allowPositionals: false,
499
+ });
500
+
501
+ const tools = values.tools === undefined ? undefined : splitPatterns(values.tools);
502
+
503
+ return {
504
+ ...(tools !== undefined && { tools }),
505
+ ...(values["read-only"] !== undefined && { isReadOnly: values["read-only"] }),
506
+ };
507
+ };
508
+
509
+ async function main(options: ServerOptions = {}): Promise<void> {
510
+ const settings = await resolveSettings(options);
511
+ const server = createMcpServer({ version, settings });
415
512
 
416
513
  installGracefulShutdown({
417
514
  onSignal: () => server.stop(),
@@ -420,4 +517,4 @@ async function main(): Promise<void> {
420
517
  await server.start();
421
518
  }
422
519
 
423
- export { log, main };
520
+ export { log, main, main as runMcpServer, parseServerArgs };
@@ -353,7 +353,7 @@ class SocketServer {
353
353
  }
354
354
 
355
355
  this.server = null;
356
- this.registry.dispose("Server stopping");
356
+ this.registry.dispose();
357
357
  await closeServer(server);
358
358
  await this.release();
359
359
  }
@@ -0,0 +1,54 @@
1
+ import type { McpSettings } from "@gtkx/config/internal";
2
+ import type { Tool } from "./tool.js";
3
+
4
+ const NEGATION_PREFIX = "!";
5
+ const SPECIAL_CHARACTERS = /[.+?^${}()|[\]\\]/g;
6
+
7
+ const escapeLiteral = (value: string): string => value.replaceAll(SPECIAL_CHARACTERS, String.raw`\$&`);
8
+
9
+ const patternToRegExp = (pattern: string): RegExp =>
10
+ new RegExp(`^${pattern.split("*").map((part) => escapeLiteral(part)).join(".*")}$`);
11
+
12
+ const matchingNames = (tools: Tool[], pattern: string): string[] => {
13
+ const expression = patternToRegExp(pattern);
14
+
15
+ return tools.filter((tool) => expression.test(tool.name)).map((tool) => tool.name);
16
+ };
17
+
18
+ const applyPattern = (selected: Set<string>, tools: Tool[], pattern: string): void => {
19
+ const isNegated = pattern.startsWith(NEGATION_PREFIX);
20
+ const names = matchingNames(tools, isNegated ? pattern.slice(NEGATION_PREFIX.length) : pattern);
21
+
22
+ for (const name of names) {
23
+ if (isNegated) {
24
+ selected.delete(name);
25
+ } else {
26
+ selected.add(name);
27
+ }
28
+ }
29
+ };
30
+
31
+ const selectNames = (tools: Tool[], patterns: string[]): Set<string> => {
32
+ const isSubtractive = patterns.every((pattern) => pattern.startsWith(NEGATION_PREFIX));
33
+ const selected: Set<string> = new Set(isSubtractive ? tools.map((tool) => tool.name) : []);
34
+
35
+ for (const pattern of patterns) {
36
+ applyPattern(selected, tools, pattern);
37
+ }
38
+
39
+ return selected;
40
+ };
41
+
42
+ const selectTools = (tools: Tool[], settings: McpSettings): Tool[] => {
43
+ const allowed = settings.isReadOnly ? tools.filter((tool) => tool.kind === "readOnly") : tools;
44
+
45
+ if (settings.tools.length === 0) {
46
+ return allowed;
47
+ }
48
+
49
+ const selected = selectNames(allowed, settings.tools);
50
+
51
+ return allowed.filter((tool) => selected.has(tool.name));
52
+ };
53
+
54
+ export { selectTools };
package/src/tool.ts CHANGED
@@ -12,6 +12,7 @@ type Tool<Shape extends Record<string, z.ZodType> = Record<string, z.ZodType>> =
12
12
  kind: ToolKind;
13
13
  description: string;
14
14
  inputSchema: Shape;
15
+ isOpenWorld?: boolean;
15
16
  handler: (args: ToolArgs<Shape>) => Promise<CallToolResult>;
16
17
  };
17
18
 
@@ -63,7 +64,7 @@ const registerTool = (server: McpServer, tool: Tool): void => {
63
64
  title: tool.title,
64
65
  readOnlyHint: tool.kind === "readOnly",
65
66
  destructiveHint: tool.kind === "action",
66
- openWorldHint: true,
67
+ openWorldHint: tool.isOpenWorld === true,
67
68
  },
68
69
  },
69
70
  callback,