@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.
@@ -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
- const socketIsLive = (socketPath: string): Promise<boolean> =>
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
- const probe = net.connect(socketPath);
23
+ setTimeout(resolve, ms);
24
+ });
10
25
 
11
- probe.once("connect", () => {
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(true);
122
+ resolve(outcome);
123
+ };
124
+
125
+ probe.once("connect", () => {
126
+ settleWith({ kind: "live" });
14
127
  });
15
128
 
16
- probe.once("error", () => {
17
- resolve(false);
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 removeStaleSocket = async (socketPath: string): Promise<void> => {
22
- if (!fs.existsSync(socketPath)) {
23
- return;
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 (await socketIsLive(socketPath)) {
27
- throw new Error(
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
- fs.unlinkSync(socketPath);
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
- async start(): Promise<void> {
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
- this.server = net.createServer((socket) => this.registry.register(socket));
269
+ const server = net.createServer((socket) => this.registry.register(socket));
55
270
  let isListening = false;
56
271
 
57
- this.server.on("error", (error) => {
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
- this.server.listen(this.socketPath, () => {
280
+ server.listen(privatePath, () => {
66
281
  isListening = true;
67
- resolve();
282
+ resolve(server);
68
283
  });
69
284
  });
70
285
  }
71
286
 
72
- async stop(): Promise<void> {
73
- if (!this.server) {
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
- this.registry.dispose("Server stopping");
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
- return new Promise((resolve) => {
80
- this.server?.close(() => {
81
- this.server = null;
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
- if (fs.existsSync(this.socketPath)) {
84
- fs.unlinkSync(this.socketPath);
85
- }
310
+ private open(): Promise<void> {
311
+ return withClaimLock(this.socketPath, () => this.bind());
312
+ }
86
313
 
87
- resolve();
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 };
package/src/transport.ts CHANGED
@@ -234,7 +234,6 @@ export {
234
234
  connectionErrorEvent,
235
235
  connectionRequestEvent,
236
236
  ProtocolConnection,
237
- type ProtocolConnectionEvents,
238
237
  type AppConnections,
239
238
  type ConnectionEvent,
240
239
  type ConnectionErrorEvent,