@gtkx/mcp 0.21.0 → 1.0.0-rc.1
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/README.md +137 -34
- package/bin/gtkx-mcp.js +8 -1
- package/dist/app-router.d.ts +35 -0
- package/dist/app-router.d.ts.map +1 -0
- package/dist/app-router.js +130 -0
- package/dist/app-router.js.map +1 -0
- package/dist/connection-registry.d.ts +12 -0
- package/dist/connection-registry.d.ts.map +1 -0
- package/dist/connection-registry.js +38 -0
- package/dist/connection-registry.js.map +1 -0
- package/dist/internal.d.ts +4 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +4 -0
- package/dist/internal.js.map +1 -0
- package/dist/protocol/errors.d.ts +24 -87
- package/dist/protocol/errors.d.ts.map +1 -1
- package/dist/protocol/errors.js +27 -91
- package/dist/protocol/errors.js.map +1 -1
- package/dist/protocol/schemas.d.ts +89 -0
- package/dist/protocol/schemas.d.ts.map +1 -0
- package/dist/protocol/schemas.js +56 -0
- package/dist/protocol/schemas.js.map +1 -0
- package/dist/reference.d.ts +13 -0
- package/dist/reference.d.ts.map +1 -0
- package/dist/reference.js +250 -0
- package/dist/reference.js.map +1 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +220 -0
- package/dist/server.js.map +1 -0
- package/dist/socket-server.d.ts +4 -38
- package/dist/socket-server.d.ts.map +1 -1
- package/dist/socket-server.js +26 -109
- package/dist/socket-server.js.map +1 -1
- package/dist/tool.d.ts +22 -0
- package/dist/tool.d.ts.map +1 -0
- package/dist/tool.js +36 -0
- package/dist/tool.js.map +1 -0
- package/dist/transport.d.ts +41 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +121 -0
- package/dist/transport.js.map +1 -0
- package/package.json +17 -11
- package/src/app-router.ts +172 -0
- package/src/connection-registry.ts +42 -0
- package/src/internal.ts +17 -0
- package/src/protocol/errors.ts +43 -99
- package/src/protocol/schemas.ts +148 -0
- package/src/reference.ts +340 -0
- package/src/server.ts +281 -0
- package/src/socket-server.ts +30 -154
- package/src/tool.ts +66 -0
- package/src/transport.ts +165 -0
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -399
- package/dist/cli.js.map +0 -1
- package/dist/connection-manager.d.ts +0 -48
- package/dist/connection-manager.d.ts.map +0 -1
- package/dist/connection-manager.js +0 -185
- package/dist/connection-manager.js.map +0 -1
- package/dist/index.d.ts +0 -5
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -5
- package/dist/index.js.map +0 -1
- package/dist/protocol/types.d.ts +0 -132
- package/dist/protocol/types.d.ts.map +0 -1
- package/dist/protocol/types.js +0 -46
- package/dist/protocol/types.js.map +0 -1
- package/src/cli.ts +0 -449
- package/src/connection-manager.ts +0 -247
- package/src/index.ts +0 -27
- package/src/protocol/types.ts +0 -153
package/src/server.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { createLogger, installGracefulShutdown, type Logger } from "@gtkx/utils";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { AppRouter } from "./app-router.js";
|
|
7
|
+
import { ConnectionRegistry } from "./connection-registry.js";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_SOCKET_PATH,
|
|
10
|
+
fireEventParams,
|
|
11
|
+
queryParams,
|
|
12
|
+
screenshotParams,
|
|
13
|
+
typeParams,
|
|
14
|
+
widgetIdParams,
|
|
15
|
+
} from "./protocol/schemas.js";
|
|
16
|
+
import { buildReferenceTools, createReferenceProvider, registerReferenceResources } from "./reference.js";
|
|
17
|
+
import { SocketServer } from "./socket-server.js";
|
|
18
|
+
import { defineTool, imageContent, registerTool, type Tool, textContent } from "./tool.js";
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const { version } = require("../package.json") as { version: string };
|
|
22
|
+
|
|
23
|
+
export const log: Logger = createLogger("mcp");
|
|
24
|
+
|
|
25
|
+
const APPLICATION_ID_DESCRIPTION = "Application ID to query. If not specified, uses the first connected app.";
|
|
26
|
+
const WIDGET_ID_DESCRIPTION =
|
|
27
|
+
"Widget ID obtained from `gtkx_get_widget_tree`, `gtkx_query_widgets`, or `gtkx_get_widget_props`. IDs are scoped to a single app. An ID stays valid for as long as its widget is mounted and stops resolving once the widget is unmounted.";
|
|
28
|
+
|
|
29
|
+
const applicationIdShape = { applicationId: z.string().optional().describe(APPLICATION_ID_DESCRIPTION) };
|
|
30
|
+
const widgetIdShape = {
|
|
31
|
+
...applicationIdShape,
|
|
32
|
+
widgetId: widgetIdParams.shape.widgetId.describe(WIDGET_ID_DESCRIPTION),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const listAppsShape = {
|
|
36
|
+
waitForApps: z
|
|
37
|
+
.boolean()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe(
|
|
40
|
+
"If true, wait for at least one app to register before returning. Useful when app is still starting.",
|
|
41
|
+
),
|
|
42
|
+
timeout: z.number().optional().describe("Timeout in milliseconds when waitForApps is true (default: 10000)"),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const queryWidgetsShape = {
|
|
46
|
+
...applicationIdShape,
|
|
47
|
+
by: queryParams.shape.by.describe("Query type"),
|
|
48
|
+
value: queryParams.shape.value.describe("Value to search for"),
|
|
49
|
+
options: queryParams.shape.options.describe("Additional query options"),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const typeShape = {
|
|
53
|
+
...widgetIdShape,
|
|
54
|
+
text: typeParams.shape.text.describe("Text to type"),
|
|
55
|
+
clear: typeParams.shape.clear.describe("Clear existing text before typing"),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const fireEventShape = {
|
|
59
|
+
...widgetIdShape,
|
|
60
|
+
signal: fireEventParams.shape.signal.describe("GTK4 signal name to emit"),
|
|
61
|
+
args: fireEventParams.shape.args.describe("Arguments to pass to the signal"),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const screenshotShape = {
|
|
65
|
+
...applicationIdShape,
|
|
66
|
+
windowId: screenshotParams.shape.windowId.describe(
|
|
67
|
+
"Window ID to capture. If not specified, captures the first window.",
|
|
68
|
+
),
|
|
69
|
+
path: screenshotParams.shape.path.describe(
|
|
70
|
+
"Absolute path to write the PNG to on the app's machine. If set, the screenshot is saved there in addition to being returned.",
|
|
71
|
+
),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const listAppsTool = (appRouter: AppRouter): Tool =>
|
|
75
|
+
defineTool({
|
|
76
|
+
name: "gtkx_list_apps",
|
|
77
|
+
title: "List apps",
|
|
78
|
+
kind: "readOnly",
|
|
79
|
+
description: "List all connected GTKX applications and their open windows.",
|
|
80
|
+
inputSchema: listAppsShape,
|
|
81
|
+
handler: async ({ waitForApps, timeout }) => {
|
|
82
|
+
if (waitForApps && !appRouter.hasConnectedApps()) {
|
|
83
|
+
await appRouter.waitForApp(timeout);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const apps = appRouter.getApps();
|
|
87
|
+
const appsWithWindows = await Promise.all(
|
|
88
|
+
apps.map(async (app) => {
|
|
89
|
+
try {
|
|
90
|
+
const result = await appRouter.sendToApp<{
|
|
91
|
+
windows: Array<{ id: string; title: string | null }>;
|
|
92
|
+
}>(app.applicationId, "app.getWindows", {});
|
|
93
|
+
return { ...app, windows: result.windows };
|
|
94
|
+
} catch {
|
|
95
|
+
return app;
|
|
96
|
+
}
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
return textContent(JSON.stringify(appsWithWindows, null, 2));
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const screenshotTool = (appRouter: AppRouter): Tool =>
|
|
104
|
+
defineTool({
|
|
105
|
+
name: "gtkx_take_screenshot",
|
|
106
|
+
title: "Take screenshot",
|
|
107
|
+
kind: "readOnly",
|
|
108
|
+
description:
|
|
109
|
+
"Capture a screenshot of a window. Returns base64-encoded PNG image data, and optionally writes the PNG to `path` on the app's machine. You can't target widgets from a screenshot; use `gtkx_get_widget_tree` to find widget IDs for interaction.",
|
|
110
|
+
inputSchema: screenshotShape,
|
|
111
|
+
handler: async ({ applicationId, ...params }) => {
|
|
112
|
+
const result = await appRouter.sendToApp<{ data: string; mimeType: string; savedPath?: string }>(
|
|
113
|
+
applicationId,
|
|
114
|
+
"widget.screenshot",
|
|
115
|
+
params,
|
|
116
|
+
);
|
|
117
|
+
if (result.savedPath) {
|
|
118
|
+
return {
|
|
119
|
+
content: [
|
|
120
|
+
{ type: "text", text: `Screenshot saved to ${result.savedPath}` },
|
|
121
|
+
{ type: "image", data: result.data, mimeType: result.mimeType },
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return imageContent(result.data, result.mimeType);
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
function buildInspectionTools(appRouter: AppRouter): Tool[] {
|
|
130
|
+
return [
|
|
131
|
+
listAppsTool(appRouter),
|
|
132
|
+
defineTool({
|
|
133
|
+
name: "gtkx_get_widget_tree",
|
|
134
|
+
title: "Widget tree",
|
|
135
|
+
kind: "readOnly",
|
|
136
|
+
description:
|
|
137
|
+
"Get the widget hierarchy for a connected GTKX app. Returns a tree of all widgets with their IDs, types, roles, and properties.",
|
|
138
|
+
inputSchema: applicationIdShape,
|
|
139
|
+
handler: async ({ applicationId }) => {
|
|
140
|
+
const result = await appRouter.sendToApp<{ tree: string }>(applicationId, "widget.getTree", {});
|
|
141
|
+
return textContent(result.tree);
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
defineTool({
|
|
145
|
+
name: "gtkx_query_widgets",
|
|
146
|
+
title: "Query widgets",
|
|
147
|
+
kind: "readOnly",
|
|
148
|
+
description:
|
|
149
|
+
"Find widgets by role, text, name, or label. Returns matching widgets with their IDs and properties.",
|
|
150
|
+
inputSchema: queryWidgetsShape,
|
|
151
|
+
handler: async ({ applicationId, ...params }) => {
|
|
152
|
+
const result = await appRouter.sendToApp(applicationId, "widget.query", params);
|
|
153
|
+
return textContent(JSON.stringify(result, null, 2));
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
defineTool({
|
|
157
|
+
name: "gtkx_get_widget_props",
|
|
158
|
+
title: "Get widget properties",
|
|
159
|
+
kind: "readOnly",
|
|
160
|
+
description:
|
|
161
|
+
"Get a fixed summary of one widget by ID: type, accessible role, name, text, sensitivity, visibility, CSS classes, and the full subtree of descendant widgets. It does not return arbitrary GObject properties.",
|
|
162
|
+
inputSchema: widgetIdShape,
|
|
163
|
+
handler: async ({ applicationId, ...params }) => {
|
|
164
|
+
const result = await appRouter.sendToApp(applicationId, "widget.getProps", params);
|
|
165
|
+
return textContent(JSON.stringify(result, null, 2));
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
screenshotTool(appRouter),
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function buildInteractionTools(appRouter: AppRouter): Tool[] {
|
|
173
|
+
return [
|
|
174
|
+
defineTool({
|
|
175
|
+
name: "gtkx_click",
|
|
176
|
+
title: "Click widget",
|
|
177
|
+
kind: "action",
|
|
178
|
+
description: "Click a widget. Works with buttons, checkboxes, and other interactive widgets.",
|
|
179
|
+
inputSchema: widgetIdShape,
|
|
180
|
+
handler: async ({ applicationId, ...params }) => {
|
|
181
|
+
await appRouter.sendToApp(applicationId, "widget.click", params);
|
|
182
|
+
return textContent("Clicked");
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
defineTool({
|
|
186
|
+
name: "gtkx_type",
|
|
187
|
+
title: "Type text",
|
|
188
|
+
kind: "action",
|
|
189
|
+
description: "Type text into an editable widget like Entry or TextView",
|
|
190
|
+
inputSchema: typeShape,
|
|
191
|
+
handler: async ({ applicationId, ...params }) => {
|
|
192
|
+
await appRouter.sendToApp(applicationId, "widget.type", params);
|
|
193
|
+
return textContent("Typed text");
|
|
194
|
+
},
|
|
195
|
+
}),
|
|
196
|
+
defineTool({
|
|
197
|
+
name: "gtkx_fire_event",
|
|
198
|
+
title: "Fire event",
|
|
199
|
+
kind: "action",
|
|
200
|
+
description: "Emit a GTK4 signal on a widget. Use this for custom interactions.",
|
|
201
|
+
inputSchema: fireEventShape,
|
|
202
|
+
handler: async ({ applicationId, ...params }) => {
|
|
203
|
+
await appRouter.sendToApp(applicationId, "widget.fireEvent", params);
|
|
204
|
+
return textContent("Fired event");
|
|
205
|
+
},
|
|
206
|
+
}),
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function buildTools(appRouter: AppRouter): Tool[] {
|
|
211
|
+
return [...buildInspectionTools(appRouter), ...buildInteractionTools(appRouter)];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
type CreateMcpServerOptions = {
|
|
215
|
+
socketPath?: string;
|
|
216
|
+
version: string;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
type McpServerHandle = {
|
|
220
|
+
start(): Promise<void>;
|
|
221
|
+
stop(): Promise<void>;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const createMcpServer = (options: CreateMcpServerOptions): McpServerHandle => {
|
|
225
|
+
const socketPath = options.socketPath ?? DEFAULT_SOCKET_PATH;
|
|
226
|
+
|
|
227
|
+
const registry = new ConnectionRegistry();
|
|
228
|
+
const socketServer = new SocketServer(registry, socketPath);
|
|
229
|
+
const appRouter = new AppRouter(registry);
|
|
230
|
+
|
|
231
|
+
registry.on("error", (error) => {
|
|
232
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
233
|
+
if (code !== "EPIPE" && code !== "ECONNRESET") {
|
|
234
|
+
log.error(`socket error: ${error.message}`);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
appRouter.on("appRegistered", (appInfo) => {
|
|
239
|
+
log.info(`app registered: ${appInfo.applicationId} (PID: ${appInfo.pid})`);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
appRouter.on("appUnregistered", (applicationId) => {
|
|
243
|
+
log.info(`app unregistered: ${applicationId}`);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const mcpServer = new McpServer({ name: "gtkx-mcp", version: options.version });
|
|
247
|
+
|
|
248
|
+
const referenceProvider = createReferenceProvider(() => appRouter.getProjectRoot() ?? process.cwd());
|
|
249
|
+
|
|
250
|
+
for (const tool of [...buildTools(appRouter), ...buildReferenceTools(referenceProvider)]) {
|
|
251
|
+
registerTool(mcpServer, tool);
|
|
252
|
+
}
|
|
253
|
+
registerReferenceResources(mcpServer, referenceProvider);
|
|
254
|
+
|
|
255
|
+
let stopped = false;
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
async start() {
|
|
259
|
+
await socketServer.start();
|
|
260
|
+
log.info(`socket server listening on ${socketPath}`);
|
|
261
|
+
const transport = new StdioServerTransport();
|
|
262
|
+
process.stdin.on("end", () => void this.stop());
|
|
263
|
+
process.stdin.on("close", () => void this.stop());
|
|
264
|
+
await mcpServer.connect(transport);
|
|
265
|
+
},
|
|
266
|
+
async stop() {
|
|
267
|
+
if (stopped) return;
|
|
268
|
+
stopped = true;
|
|
269
|
+
await socketServer.stop();
|
|
270
|
+
await mcpServer.close();
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export async function main(): Promise<void> {
|
|
276
|
+
const server = createMcpServer({ version });
|
|
277
|
+
installGracefulShutdown({
|
|
278
|
+
onSignal: () => server.stop(),
|
|
279
|
+
});
|
|
280
|
+
await server.start();
|
|
281
|
+
}
|
package/src/socket-server.ts
CHANGED
|
@@ -1,99 +1,61 @@
|
|
|
1
|
-
import EventEmitter from "node:events";
|
|
2
1
|
import * as fs from "node:fs";
|
|
3
2
|
import * as net from "node:net";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
disconnection: [AppConnection];
|
|
17
|
-
request: [AppConnection, IpcRequest];
|
|
18
|
-
response: [AppConnection, IpcResponse];
|
|
19
|
-
error: [Error];
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Represents a connected application.
|
|
24
|
-
*/
|
|
25
|
-
export type AppConnection = {
|
|
26
|
-
/** Unique connection identifier */
|
|
27
|
-
id: string;
|
|
28
|
-
/** The underlying socket */
|
|
29
|
-
socket: net.Socket;
|
|
30
|
-
/** Buffer for incomplete messages */
|
|
31
|
-
buffer: string;
|
|
32
|
-
};
|
|
3
|
+
import type { ConnectionRegistry } from "./connection-registry.js";
|
|
4
|
+
import { DEFAULT_SOCKET_PATH } from "./protocol/schemas.js";
|
|
5
|
+
|
|
6
|
+
const isSocketLive = (socketPath: string): Promise<boolean> =>
|
|
7
|
+
new Promise((resolve) => {
|
|
8
|
+
const probe = net.connect(socketPath);
|
|
9
|
+
probe.once("connect", () => {
|
|
10
|
+
probe.destroy();
|
|
11
|
+
resolve(true);
|
|
12
|
+
});
|
|
13
|
+
probe.once("error", () => resolve(false));
|
|
14
|
+
});
|
|
33
15
|
|
|
34
|
-
|
|
35
|
-
* Unix domain socket server for MCP communication.
|
|
36
|
-
*
|
|
37
|
-
* Manages connections from GTKX applications and handles IPC messaging.
|
|
38
|
-
*/
|
|
39
|
-
export class SocketServer extends EventEmitter<SocketServerEventMap> {
|
|
16
|
+
export class SocketServer {
|
|
40
17
|
private server: net.Server | null = null;
|
|
41
|
-
private connections: Map<string, AppConnection> = new Map();
|
|
42
18
|
private socketPath: string;
|
|
19
|
+
private registry: ConnectionRegistry;
|
|
43
20
|
|
|
44
|
-
constructor(socketPath: string = DEFAULT_SOCKET_PATH) {
|
|
45
|
-
|
|
21
|
+
constructor(registry: ConnectionRegistry, socketPath: string = DEFAULT_SOCKET_PATH) {
|
|
22
|
+
this.registry = registry;
|
|
46
23
|
this.socketPath = socketPath;
|
|
47
24
|
}
|
|
48
25
|
|
|
49
|
-
get path(): string {
|
|
50
|
-
return this.socketPath;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
get isListening(): boolean {
|
|
54
|
-
return this.server?.listening ?? false;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
getConnections(): AppConnection[] {
|
|
58
|
-
return Array.from(this.connections.values());
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
getConnection(id: string): AppConnection | undefined {
|
|
62
|
-
return this.connections.get(id);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
26
|
async start(): Promise<void> {
|
|
66
|
-
if (this.server)
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
27
|
+
if (this.server) return;
|
|
69
28
|
|
|
70
29
|
if (fs.existsSync(this.socketPath)) {
|
|
30
|
+
if (await isSocketLive(this.socketPath)) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Another GTKX MCP server already owns ${this.socketPath}. ` +
|
|
33
|
+
"Stop the other server (for example, the gtkx MCP server of another active session) and reconnect.",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
71
36
|
fs.unlinkSync(this.socketPath);
|
|
72
37
|
}
|
|
73
38
|
|
|
74
39
|
return new Promise((resolve, reject) => {
|
|
75
|
-
this.server = net.createServer((socket) => this.
|
|
40
|
+
this.server = net.createServer((socket) => this.registry.register(socket));
|
|
76
41
|
|
|
42
|
+
let listening = false;
|
|
77
43
|
this.server.on("error", (error) => {
|
|
78
|
-
this.emit("error", error);
|
|
79
|
-
reject(error);
|
|
44
|
+
this.registry.emit("error", error);
|
|
45
|
+
if (!listening) reject(error);
|
|
80
46
|
});
|
|
81
47
|
|
|
82
48
|
this.server.listen(this.socketPath, () => {
|
|
49
|
+
listening = true;
|
|
83
50
|
resolve();
|
|
84
51
|
});
|
|
85
52
|
});
|
|
86
53
|
}
|
|
87
54
|
|
|
88
55
|
async stop(): Promise<void> {
|
|
89
|
-
if (!this.server)
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
56
|
+
if (!this.server) return;
|
|
92
57
|
|
|
93
|
-
|
|
94
|
-
connection.socket.destroy();
|
|
95
|
-
}
|
|
96
|
-
this.connections.clear();
|
|
58
|
+
this.registry.dispose("Server stopping");
|
|
97
59
|
|
|
98
60
|
return new Promise((resolve) => {
|
|
99
61
|
this.server?.close(() => {
|
|
@@ -105,90 +67,4 @@ export class SocketServer extends EventEmitter<SocketServerEventMap> {
|
|
|
105
67
|
});
|
|
106
68
|
});
|
|
107
69
|
}
|
|
108
|
-
|
|
109
|
-
send(connectionId: string, message: IpcMessage): boolean {
|
|
110
|
-
const connection = this.connections.get(connectionId);
|
|
111
|
-
if (!connection?.socket.writable) {
|
|
112
|
-
return false;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const data = `${JSON.stringify(message)}\n`;
|
|
116
|
-
connection.socket.write(data);
|
|
117
|
-
return true;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
private handleConnection(socket: net.Socket): void {
|
|
121
|
-
const connectionId = crypto.randomUUID();
|
|
122
|
-
const connection: AppConnection = {
|
|
123
|
-
id: connectionId,
|
|
124
|
-
socket,
|
|
125
|
-
buffer: "",
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
this.connections.set(connectionId, connection);
|
|
129
|
-
this.emit("connection", connection);
|
|
130
|
-
|
|
131
|
-
socket.on("data", (data: Buffer) => this.handleData(connection, data));
|
|
132
|
-
|
|
133
|
-
socket.on("close", () => {
|
|
134
|
-
this.connections.delete(connectionId);
|
|
135
|
-
this.emit("disconnection", connection);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
socket.on("error", (error) => {
|
|
139
|
-
this.emit("error", error);
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
private handleData(connection: AppConnection, data: Buffer): void {
|
|
144
|
-
connection.buffer += data.toString();
|
|
145
|
-
|
|
146
|
-
let newlineIndex = connection.buffer.indexOf("\n");
|
|
147
|
-
while (newlineIndex !== -1) {
|
|
148
|
-
const line = connection.buffer.slice(0, newlineIndex);
|
|
149
|
-
connection.buffer = connection.buffer.slice(newlineIndex + 1);
|
|
150
|
-
|
|
151
|
-
if (line.trim()) {
|
|
152
|
-
this.processMessage(connection, line);
|
|
153
|
-
}
|
|
154
|
-
newlineIndex = connection.buffer.indexOf("\n");
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
private processMessage(connection: AppConnection, line: string): void {
|
|
159
|
-
let parsed: unknown;
|
|
160
|
-
try {
|
|
161
|
-
parsed = JSON.parse(line);
|
|
162
|
-
} catch {
|
|
163
|
-
const response: IpcResponse = {
|
|
164
|
-
id: "unknown",
|
|
165
|
-
error: invalidRequestError("Invalid JSON").toIpcError(),
|
|
166
|
-
};
|
|
167
|
-
this.send(connection.id, response);
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const message = parsed as Record<string, unknown>;
|
|
172
|
-
const hasMethod = typeof message.method === "string";
|
|
173
|
-
|
|
174
|
-
if (hasMethod) {
|
|
175
|
-
const requestResult = IpcRequestSchema.safeParse(parsed);
|
|
176
|
-
if (requestResult.success) {
|
|
177
|
-
this.emit("request", connection, requestResult.data);
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
} else {
|
|
181
|
-
const responseResult = IpcResponseSchema.safeParse(parsed);
|
|
182
|
-
if (responseResult.success) {
|
|
183
|
-
this.emit("response", connection, responseResult.data);
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const response: IpcResponse = {
|
|
189
|
-
id: (message.id as string | undefined) ?? "unknown",
|
|
190
|
-
error: invalidRequestError("Invalid message format").toIpcError(),
|
|
191
|
-
};
|
|
192
|
-
this.send(connection.id, response);
|
|
193
|
-
}
|
|
194
70
|
}
|
package/src/tool.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { McpServer, ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import type { z } from "zod";
|
|
4
|
+
import { ProtocolError } from "./protocol/errors.js";
|
|
5
|
+
|
|
6
|
+
export const textContent = (text: string): CallToolResult => ({ content: [{ type: "text", text }] });
|
|
7
|
+
|
|
8
|
+
export const textError = (text: string): CallToolResult => ({
|
|
9
|
+
content: [{ type: "text", text }],
|
|
10
|
+
isError: true,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const imageContent = (data: string, mimeType: string): CallToolResult => ({
|
|
14
|
+
content: [{ type: "image", data, mimeType }],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export type ToolArgs<Shape extends Record<string, z.ZodType>> = { [K in keyof Shape]: z.output<Shape[K]> };
|
|
18
|
+
|
|
19
|
+
type ToolKind = "readOnly" | "action";
|
|
20
|
+
|
|
21
|
+
export type Tool<Shape extends Record<string, z.ZodType> = Record<string, z.ZodType>> = {
|
|
22
|
+
name: string;
|
|
23
|
+
title: string;
|
|
24
|
+
kind: ToolKind;
|
|
25
|
+
description: string;
|
|
26
|
+
inputSchema: Shape;
|
|
27
|
+
handler: (args: ToolArgs<Shape>) => Promise<CallToolResult>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const hasStringHint = (data: unknown): data is { hint: string } =>
|
|
31
|
+
typeof data === "object" && data !== null && "hint" in data && typeof data.hint === "string";
|
|
32
|
+
|
|
33
|
+
const runTool = async (
|
|
34
|
+
handler: (args: ToolArgs<Record<string, z.ZodType>>) => Promise<CallToolResult>,
|
|
35
|
+
args: ToolArgs<Record<string, z.ZodType>>,
|
|
36
|
+
): Promise<CallToolResult> => {
|
|
37
|
+
try {
|
|
38
|
+
return await handler(args);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error instanceof ProtocolError) {
|
|
41
|
+
return textError(hasStringHint(error.data) ? `${error.message}\n${error.data.hint}` : error.message);
|
|
42
|
+
}
|
|
43
|
+
return textError(error instanceof Error ? error.message : String(error));
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const defineTool = <Shape extends Record<string, z.ZodType>>(tool: Tool<Shape>): Tool => tool as Tool;
|
|
48
|
+
|
|
49
|
+
export const registerTool = (server: McpServer, tool: Tool): void => {
|
|
50
|
+
const callback = ((args: ToolArgs<Record<string, z.ZodType>>, _extra: unknown) =>
|
|
51
|
+
runTool(tool.handler, args)) as ToolCallback<Record<string, z.ZodType>>;
|
|
52
|
+
server.registerTool(
|
|
53
|
+
tool.name,
|
|
54
|
+
{
|
|
55
|
+
description: tool.description,
|
|
56
|
+
inputSchema: tool.inputSchema,
|
|
57
|
+
annotations: {
|
|
58
|
+
title: tool.title,
|
|
59
|
+
readOnlyHint: tool.kind === "readOnly",
|
|
60
|
+
destructiveHint: tool.kind === "action",
|
|
61
|
+
openWorldHint: true,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
callback,
|
|
65
|
+
);
|
|
66
|
+
};
|