@gtkx/mcp 1.0.0-rc.3 → 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/README.md +8 -9
- package/dist/internal.d.ts +2 -2
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +2 -2
- package/dist/internal.js.map +1 -1
- package/dist/protocol/errors.d.ts +3 -1
- package/dist/protocol/errors.d.ts.map +1 -1
- package/dist/protocol/errors.js +9 -1
- package/dist/protocol/errors.js.map +1 -1
- package/dist/protocol/schemas.d.ts +18 -4
- package/dist/protocol/schemas.d.ts.map +1 -1
- package/dist/protocol/schemas.js +10 -3
- package/dist/protocol/schemas.js.map +1 -1
- package/dist/reference.d.ts +17 -3
- package/dist/reference.d.ts.map +1 -1
- package/dist/reference.js +123 -64
- package/dist/reference.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +120 -57
- package/dist/server.js.map +1 -1
- package/dist/socket-server.d.ts +10 -1
- package/dist/socket-server.d.ts.map +1 -1
- package/dist/socket-server.js +234 -33
- package/dist/socket-server.js.map +1 -1
- package/dist/transport.d.ts +1 -1
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js.map +1 -1
- package/package.json +4 -4
- package/src/internal.ts +4 -0
- package/src/protocol/errors.ts +14 -0
- package/src/protocol/schemas.ts +32 -6
- package/src/reference.ts +206 -83
- package/src/server.ts +162 -72
- package/src/socket-server.ts +309 -40
- package/src/transport.ts +0 -1
package/src/socket-server.ts
CHANGED
|
@@ -1,60 +1,275 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as net from "node:net";
|
|
4
|
+
import { basename, dirname, join, resolve as resolvePath } from "node:path";
|
|
3
5
|
import type { ConnectionRegistry } from "./connection-registry.js";
|
|
4
6
|
import { DEFAULT_SOCKET_PATH } from "./protocol/schemas.js";
|
|
5
7
|
import { connectionErrorEvent } from "./transport.js";
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
type ProbeOutcome = { kind: "live" } | { kind: "unknown"; code: string } | { kind: "vacant" };
|
|
10
|
+
type PathVerdict = ProbeOutcome | { kind: "directory" };
|
|
11
|
+
type ClaimOutcome = "occupied" | "published";
|
|
12
|
+
|
|
13
|
+
const PROBE_TIMEOUT_MS = 1000;
|
|
14
|
+
const PROBE_ATTEMPTS = 3;
|
|
15
|
+
const PROBE_RETRY_DELAY_MS = 50;
|
|
16
|
+
const CLAIM_ATTEMPTS = 3;
|
|
17
|
+
const CLAIM_LOCK_PREFIX = "\0gtkx-mcp-claim-";
|
|
18
|
+
const CLAIM_LOCK_RETRY_DELAY_MS = 25;
|
|
19
|
+
const CLAIM_LOCK_TIMEOUT_MS = 15_000;
|
|
20
|
+
|
|
21
|
+
const delay = (ms: number): Promise<void> =>
|
|
8
22
|
new Promise((resolve) => {
|
|
9
|
-
|
|
23
|
+
setTimeout(resolve, ms);
|
|
24
|
+
});
|
|
10
25
|
|
|
11
|
-
|
|
26
|
+
const closeServer = (server: net.Server): Promise<void> =>
|
|
27
|
+
new Promise((resolve) => {
|
|
28
|
+
server.close(() => {
|
|
29
|
+
resolve();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const digestFor = (socketPath: string): string =>
|
|
34
|
+
createHash("sha256").update(resolvePath(socketPath)).digest("hex");
|
|
35
|
+
|
|
36
|
+
const privatePathFor = (socketPath: string): string => {
|
|
37
|
+
const width = Math.max(1, basename(socketPath).length - 1);
|
|
38
|
+
|
|
39
|
+
return join(dirname(socketPath), `.${digestFor(socketPath).slice(0, width)}`);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const bindLock = (address: string): Promise<net.Server | null> =>
|
|
43
|
+
new Promise((resolve) => {
|
|
44
|
+
const lock = net.createServer();
|
|
45
|
+
let isBound = false;
|
|
46
|
+
|
|
47
|
+
lock.on("error", () => {
|
|
48
|
+
if (!isBound) {
|
|
49
|
+
resolve(null);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
lock.listen(address, () => {
|
|
54
|
+
isBound = true;
|
|
55
|
+
resolve(lock);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const claimBlockedError = (socketPath: string): Error =>
|
|
60
|
+
new Error(
|
|
61
|
+
`Timed out waiting for another GTKX MCP server to finish claiming ${socketPath}. ` +
|
|
62
|
+
"Retry once no other server is starting on that path.",
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const acquireClaimLock = async (socketPath: string): Promise<net.Server | null> => {
|
|
66
|
+
const address = `${CLAIM_LOCK_PREFIX}${digestFor(socketPath)}`;
|
|
67
|
+
const deadline = Date.now() + CLAIM_LOCK_TIMEOUT_MS;
|
|
68
|
+
let lock = await bindLock(address);
|
|
69
|
+
|
|
70
|
+
while (lock === null && Date.now() < deadline) {
|
|
71
|
+
await delay(CLAIM_LOCK_RETRY_DELAY_MS);
|
|
72
|
+
lock = await bindLock(address);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return lock;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const releaseClaimLock = async (lock: net.Server | null): Promise<void> => {
|
|
79
|
+
if (lock) {
|
|
80
|
+
await closeServer(lock);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const withClaimLock = async <T>(socketPath: string, action: () => Promise<T> | T): Promise<T> => {
|
|
85
|
+
const lock = await acquireClaimLock(socketPath);
|
|
86
|
+
|
|
87
|
+
if (lock === null) {
|
|
88
|
+
throw claimBlockedError(socketPath);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
return await action();
|
|
93
|
+
} finally {
|
|
94
|
+
await closeServer(lock);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const entryFor = (target: string): fs.Stats | null => {
|
|
99
|
+
try {
|
|
100
|
+
return fs.lstatSync(target);
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const inodeFor = (target: string): number | null => entryFor(target)?.ino ?? null;
|
|
107
|
+
|
|
108
|
+
const outcomeForProbeError = (error: NodeJS.ErrnoException): ProbeOutcome => {
|
|
109
|
+
if (error.code === "ECONNREFUSED" || error.code === "ENOENT") {
|
|
110
|
+
return { kind: "vacant" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { kind: "unknown", code: error.code ?? error.message };
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const probeSocket = (target: string): Promise<ProbeOutcome> =>
|
|
117
|
+
new Promise((resolve) => {
|
|
118
|
+
const probe = net.connect({ path: target, timeout: PROBE_TIMEOUT_MS });
|
|
119
|
+
|
|
120
|
+
const settleWith = (outcome: ProbeOutcome): void => {
|
|
12
121
|
probe.destroy();
|
|
13
|
-
resolve(
|
|
122
|
+
resolve(outcome);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
probe.once("connect", () => {
|
|
126
|
+
settleWith({ kind: "live" });
|
|
14
127
|
});
|
|
15
128
|
|
|
16
|
-
probe.once("
|
|
17
|
-
|
|
129
|
+
probe.once("timeout", () => {
|
|
130
|
+
settleWith({ kind: "unknown", code: "ETIMEDOUT" });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
probe.once("error", (error: NodeJS.ErrnoException) => {
|
|
134
|
+
settleWith(outcomeForProbeError(error));
|
|
18
135
|
});
|
|
19
136
|
});
|
|
20
137
|
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
138
|
+
const probeUntilConclusive = async (target: string): Promise<ProbeOutcome> => {
|
|
139
|
+
let outcome = await probeSocket(target);
|
|
140
|
+
|
|
141
|
+
for (let attempt = 1; attempt < PROBE_ATTEMPTS && outcome.kind === "unknown"; attempt += 1) {
|
|
142
|
+
await delay(PROBE_RETRY_DELAY_MS);
|
|
143
|
+
outcome = await probeSocket(target);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return outcome;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const alreadyOwnedError = (socketPath: string): Error =>
|
|
150
|
+
new Error(
|
|
151
|
+
`Another GTKX MCP server already owns ${socketPath}. ` +
|
|
152
|
+
"Stop the other server (for example, the GTKX MCP server of another active session) and reconnect.",
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const undecidedOwnerError = (socketPath: string, code: string): Error =>
|
|
156
|
+
new Error(
|
|
157
|
+
`Could not tell whether another GTKX MCP server owns ${socketPath}: probing it failed with ${code}. ` +
|
|
158
|
+
"Leaving the socket in place instead of removing one that may still be serving another session. " +
|
|
159
|
+
"Retry, or delete the file by hand once no server is running.",
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const directoryPathError = (socketPath: string): Error =>
|
|
163
|
+
new Error(
|
|
164
|
+
`The GTKX MCP socket path ${socketPath} is a directory, not a socket. ` +
|
|
165
|
+
"Remove it, or point XDG_RUNTIME_DIR at a directory where GTKX can create its socket.",
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const listenFailureError = (socketPath: string, code: string): Error =>
|
|
169
|
+
new Error(
|
|
170
|
+
`Could not create the GTKX MCP socket at ${socketPath}: listening failed with ${code}. ` +
|
|
171
|
+
"Check that its directory exists and is writable.",
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const removeEntry = (target: string, inode: number): void => {
|
|
175
|
+
if (inodeFor(target) === inode) {
|
|
176
|
+
fs.rmSync(target, { force: true });
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const clearStalePath = async (target: string): Promise<PathVerdict> => {
|
|
181
|
+
const entry = entryFor(target);
|
|
182
|
+
|
|
183
|
+
if (entry === null) {
|
|
184
|
+
return { kind: "vacant" };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (entry.isDirectory()) {
|
|
188
|
+
return { kind: "directory" };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const outcome = await probeUntilConclusive(target);
|
|
192
|
+
|
|
193
|
+
if (outcome.kind === "vacant") {
|
|
194
|
+
removeEntry(target, entry.ino);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return outcome;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const requireVacantPath = async (socketPath: string): Promise<void> => {
|
|
201
|
+
const verdict = await clearStalePath(socketPath);
|
|
202
|
+
|
|
203
|
+
if (verdict.kind === "live") {
|
|
204
|
+
throw alreadyOwnedError(socketPath);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (verdict.kind === "directory") {
|
|
208
|
+
throw directoryPathError(socketPath);
|
|
24
209
|
}
|
|
25
210
|
|
|
26
|
-
if (
|
|
27
|
-
throw
|
|
28
|
-
`Another GTKX MCP server already owns ${socketPath}. ` +
|
|
29
|
-
"Stop the other server (for example, the gtkx MCP server of another active session) and reconnect.",
|
|
30
|
-
);
|
|
211
|
+
if (verdict.kind === "unknown") {
|
|
212
|
+
throw undecidedOwnerError(socketPath, verdict.code);
|
|
31
213
|
}
|
|
214
|
+
};
|
|
32
215
|
|
|
33
|
-
|
|
216
|
+
const claimSocketPath = (privatePath: string, socketPath: string): ClaimOutcome => {
|
|
217
|
+
try {
|
|
218
|
+
fs.linkSync(privatePath, socketPath);
|
|
219
|
+
|
|
220
|
+
return "published";
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
223
|
+
return "occupied";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const publishSocket = async (privatePath: string, socketPath: string): Promise<number | null> => {
|
|
231
|
+
for (let attempt = 1; attempt <= CLAIM_ATTEMPTS; attempt += 1) {
|
|
232
|
+
await requireVacantPath(socketPath);
|
|
233
|
+
|
|
234
|
+
if (claimSocketPath(privatePath, socketPath) === "published") {
|
|
235
|
+
const inode = inodeFor(socketPath);
|
|
236
|
+
fs.rmSync(privatePath, { force: true });
|
|
237
|
+
|
|
238
|
+
return inode;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
throw alreadyOwnedError(socketPath);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const releaseSocketPath = async (socketPath: string, inode: number): Promise<void> => {
|
|
246
|
+
const lock = await acquireClaimLock(socketPath);
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
removeEntry(socketPath, inode);
|
|
250
|
+
} finally {
|
|
251
|
+
await releaseClaimLock(lock);
|
|
252
|
+
}
|
|
34
253
|
};
|
|
35
254
|
|
|
36
255
|
class SocketServer {
|
|
37
256
|
private server: net.Server | null = null;
|
|
38
257
|
private socketPath: string;
|
|
39
258
|
private registry: ConnectionRegistry;
|
|
259
|
+
private boundInode: number | null = null;
|
|
260
|
+
private startup: Promise<void> | null = null;
|
|
40
261
|
|
|
41
262
|
constructor(registry: ConnectionRegistry, socketPath: string = DEFAULT_SOCKET_PATH) {
|
|
42
263
|
this.registry = registry;
|
|
43
264
|
this.socketPath = socketPath;
|
|
44
265
|
}
|
|
45
266
|
|
|
46
|
-
|
|
47
|
-
if (this.server) {
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
await removeStaleSocket(this.socketPath);
|
|
52
|
-
|
|
267
|
+
private listen(privatePath: string): Promise<net.Server> {
|
|
53
268
|
return new Promise((resolve, reject) => {
|
|
54
|
-
|
|
269
|
+
const server = net.createServer((socket) => this.registry.register(socket));
|
|
55
270
|
let isListening = false;
|
|
56
271
|
|
|
57
|
-
|
|
272
|
+
server.on("error", (error) => {
|
|
58
273
|
this.registry.dispatchEvent(connectionErrorEvent(error));
|
|
59
274
|
|
|
60
275
|
if (!isListening) {
|
|
@@ -62,32 +277,86 @@ class SocketServer {
|
|
|
62
277
|
}
|
|
63
278
|
});
|
|
64
279
|
|
|
65
|
-
|
|
280
|
+
server.listen(privatePath, () => {
|
|
66
281
|
isListening = true;
|
|
67
|
-
resolve();
|
|
282
|
+
resolve(server);
|
|
68
283
|
});
|
|
69
284
|
});
|
|
70
285
|
}
|
|
71
286
|
|
|
72
|
-
async
|
|
73
|
-
|
|
74
|
-
return;
|
|
287
|
+
private async listenPrivately(privatePath: string): Promise<net.Server> {
|
|
288
|
+
try {
|
|
289
|
+
return await this.listen(privatePath);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
const failure = error as NodeJS.ErrnoException;
|
|
292
|
+
throw listenFailureError(this.socketPath, failure.code ?? failure.message);
|
|
75
293
|
}
|
|
294
|
+
}
|
|
76
295
|
|
|
77
|
-
|
|
296
|
+
private async bind(): Promise<void> {
|
|
297
|
+
const privatePath = privatePathFor(this.socketPath);
|
|
298
|
+
await clearStalePath(privatePath);
|
|
299
|
+
const server = await this.listenPrivately(privatePath);
|
|
78
300
|
|
|
79
|
-
|
|
80
|
-
this.
|
|
81
|
-
|
|
301
|
+
try {
|
|
302
|
+
this.boundInode = await publishSocket(privatePath, this.socketPath);
|
|
303
|
+
this.server = server;
|
|
304
|
+
} catch (error) {
|
|
305
|
+
await closeServer(server);
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
82
309
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
310
|
+
private open(): Promise<void> {
|
|
311
|
+
return withClaimLock(this.socketPath, () => this.bind());
|
|
312
|
+
}
|
|
86
313
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
314
|
+
private async settleStartup(): Promise<void> {
|
|
315
|
+
const startup = this.startup;
|
|
316
|
+
this.startup = null;
|
|
317
|
+
|
|
318
|
+
if (startup) {
|
|
319
|
+
await Promise.allSettled([startup]);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private async release(): Promise<void> {
|
|
324
|
+
const inode = this.boundInode;
|
|
325
|
+
this.boundInode = null;
|
|
326
|
+
|
|
327
|
+
if (inode !== null) {
|
|
328
|
+
await releaseSocketPath(this.socketPath, inode);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async start(): Promise<void> {
|
|
333
|
+
this.startup ??= this.open();
|
|
334
|
+
const startup = this.startup;
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
await startup;
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (this.startup === startup) {
|
|
340
|
+
this.startup = null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async stop(): Promise<void> {
|
|
348
|
+
await this.settleStartup();
|
|
349
|
+
const server = this.server;
|
|
350
|
+
|
|
351
|
+
if (!server) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
this.server = null;
|
|
356
|
+
this.registry.dispose("Server stopping");
|
|
357
|
+
await closeServer(server);
|
|
358
|
+
await this.release();
|
|
90
359
|
}
|
|
91
360
|
}
|
|
92
361
|
|
|
93
|
-
export { SocketServer };
|
|
362
|
+
export { SocketServer, withClaimLock };
|