gopeak 2.0.1 → 2.2.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.
@@ -0,0 +1,470 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { EventEmitter } from 'node:events';
3
+ import http from 'node:http';
4
+ import { WebSocket, WebSocketServer } from 'ws';
5
+ const DEFAULT_PORT = 6505;
6
+ const DEFAULT_TIMEOUT_MS = 30_000;
7
+ const KEEPALIVE_INTERVAL_MS = 10_000;
8
+ const SECOND_CONNECTION_CLOSE_CODE = 4000;
9
+ export class GodotBridge extends EventEmitter {
10
+ port;
11
+ timeoutMs;
12
+ httpServer = null;
13
+ godotWss = null;
14
+ vizWss = null;
15
+ socket = null;
16
+ pingInterval = null;
17
+ connectionInfo = null;
18
+ pendingRequests = new Map();
19
+ resourceQueues = new Map();
20
+ visualizerHtml = this.getDefaultVisualizerHtml();
21
+ constructor(port = DEFAULT_PORT, timeoutMs = DEFAULT_TIMEOUT_MS) {
22
+ super();
23
+ this.port = port;
24
+ this.timeoutMs = timeoutMs;
25
+ }
26
+ start() {
27
+ if (this.httpServer) {
28
+ return Promise.resolve();
29
+ }
30
+ return new Promise((resolve, reject) => {
31
+ const server = http.createServer((req, res) => {
32
+ this.handleHttpRequest(req, res);
33
+ });
34
+ const godotWss = new WebSocketServer({ noServer: true });
35
+ const vizWss = new WebSocketServer({ noServer: true });
36
+ let settled = false;
37
+ server.on('upgrade', (request, socket, head) => {
38
+ const pathname = this.getRequestPathname(request.url);
39
+ const target = pathname === '/godot' ? godotWss : vizWss;
40
+ target.handleUpgrade(request, socket, head, (ws) => {
41
+ target.emit('connection', ws, request);
42
+ });
43
+ });
44
+ godotWss.on('connection', (socket) => {
45
+ this.handleConnection(socket);
46
+ });
47
+ server.once('listening', () => {
48
+ settled = true;
49
+ this.httpServer = server;
50
+ this.godotWss = godotWss;
51
+ this.vizWss = vizWss;
52
+ this.log('info', `Unified HTTP+WS bridge listening on port ${this.port}`);
53
+ resolve();
54
+ });
55
+ server.once('error', (error) => {
56
+ if (!settled) {
57
+ settled = true;
58
+ reject(error);
59
+ return;
60
+ }
61
+ this.log('error', `HTTP server error: ${error.message}`);
62
+ });
63
+ godotWss.on('error', (error) => {
64
+ this.log('error', `Godot WebSocket server error: ${error.message}`);
65
+ });
66
+ vizWss.on('error', (error) => {
67
+ this.log('error', `Visualizer WebSocket server error: ${error.message}`);
68
+ });
69
+ server.listen(this.port);
70
+ });
71
+ }
72
+ stop() {
73
+ this.stopKeepalive();
74
+ this.rejectAllPending(new Error('GodotBridge stopped'));
75
+ this.resourceQueues.clear();
76
+ if (this.socket) {
77
+ try {
78
+ this.socket.close();
79
+ }
80
+ catch {
81
+ }
82
+ this.socket = null;
83
+ }
84
+ if (this.godotWss) {
85
+ for (const client of this.godotWss.clients) {
86
+ try {
87
+ client.close();
88
+ }
89
+ catch {
90
+ }
91
+ }
92
+ this.godotWss.close();
93
+ this.godotWss = null;
94
+ }
95
+ if (this.vizWss) {
96
+ for (const client of this.vizWss.clients) {
97
+ try {
98
+ client.close();
99
+ }
100
+ catch {
101
+ }
102
+ }
103
+ this.vizWss.close();
104
+ this.vizWss = null;
105
+ }
106
+ if (this.httpServer) {
107
+ this.httpServer.close();
108
+ this.httpServer = null;
109
+ }
110
+ this.connectionInfo = null;
111
+ this.visualizerHtml = this.getDefaultVisualizerHtml();
112
+ this.log('info', 'WebSocket bridge stopped');
113
+ }
114
+ isConnected() {
115
+ return this.socket?.readyState === WebSocket.OPEN;
116
+ }
117
+ getStatus() {
118
+ return {
119
+ port: this.port,
120
+ connected: this.isConnected(),
121
+ projectPath: this.connectionInfo?.projectPath,
122
+ connectedAt: this.connectionInfo?.connectedAt,
123
+ lastPongAt: this.connectionInfo?.lastPongAt,
124
+ pendingRequests: this.pendingRequests.size,
125
+ queuedResources: this.resourceQueues.size,
126
+ };
127
+ }
128
+ invokeTool(toolName, args) {
129
+ const resourceKey = this.getResourceKey(args);
130
+ if (!resourceKey) {
131
+ return this.invokeToolDirect(toolName, args);
132
+ }
133
+ return this.enqueueResourceRequest(resourceKey, () => this.invokeToolDirect(toolName, args, resourceKey));
134
+ }
135
+ getVisualizerWss() {
136
+ return this.vizWss;
137
+ }
138
+ broadcastToVisualizer(message) {
139
+ if (!this.vizWss) {
140
+ return;
141
+ }
142
+ const payload = JSON.stringify(message);
143
+ this.vizWss.clients.forEach((client) => {
144
+ if (client.readyState === WebSocket.OPEN) {
145
+ client.send(payload);
146
+ }
147
+ });
148
+ }
149
+ setVisualizerHtml(html) {
150
+ this.visualizerHtml = html;
151
+ }
152
+ handleHttpRequest(req, res) {
153
+ res.setHeader('Access-Control-Allow-Origin', '*');
154
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
155
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept');
156
+ if (req.method === 'OPTIONS') {
157
+ res.writeHead(204);
158
+ res.end();
159
+ return;
160
+ }
161
+ if (req.method === 'POST' && (this.getRequestPathname(req.url) === '/' || this.getRequestPathname(req.url) === '/mcp')) {
162
+ let body = '';
163
+ req.on('data', (chunk) => {
164
+ body += chunk.toString();
165
+ });
166
+ req.on('end', () => {
167
+ try {
168
+ const parsed = JSON.parse(body);
169
+ if (parsed.method === 'initialize') {
170
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
171
+ res.end(JSON.stringify({
172
+ jsonrpc: '2.0',
173
+ id: typeof parsed.id === 'number' || typeof parsed.id === 'string' ? parsed.id : 1,
174
+ result: {
175
+ protocolVersion: typeof parsed.params?.protocolVersion === 'string' ? parsed.params.protocolVersion : '2025-06-18',
176
+ capabilities: {},
177
+ serverInfo: { name: 'godot-mcp', version: '2.0.1' },
178
+ },
179
+ }));
180
+ return;
181
+ }
182
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
183
+ res.end(JSON.stringify({ error: 'Unsupported method' }));
184
+ }
185
+ catch {
186
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
187
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
188
+ }
189
+ });
190
+ return;
191
+ }
192
+ if (req.method !== 'GET') {
193
+ res.writeHead(405, { 'Content-Type': 'application/json' });
194
+ res.end(JSON.stringify({ error: 'Method not allowed' }));
195
+ return;
196
+ }
197
+ const pathname = this.getRequestPathname(req.url);
198
+ if (pathname === '/health') {
199
+ const payload = {
200
+ status: 'ok',
201
+ serverName: 'godot-mcp',
202
+ version: '2.0.1',
203
+ bridge: this.getStatus(),
204
+ uptime: process.uptime(),
205
+ timestamp: new Date().toISOString(),
206
+ };
207
+ res.writeHead(200, {
208
+ 'Content-Type': 'application/json; charset=utf-8',
209
+ 'Cache-Control': 'no-cache',
210
+ });
211
+ res.end(JSON.stringify(payload));
212
+ return;
213
+ }
214
+ if (pathname === '/' || pathname === '/index.html') {
215
+ res.writeHead(200, {
216
+ 'Content-Type': 'text/html; charset=utf-8',
217
+ 'Cache-Control': 'no-cache',
218
+ });
219
+ res.end(this.visualizerHtml);
220
+ return;
221
+ }
222
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
223
+ res.end(JSON.stringify({ error: 'Not found' }));
224
+ }
225
+ getRequestPathname(url) {
226
+ try {
227
+ return new URL(url ?? '/', `http://localhost:${this.port}`).pathname;
228
+ }
229
+ catch {
230
+ return '/';
231
+ }
232
+ }
233
+ handleConnection(nextSocket) {
234
+ if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
235
+ this.log('warn', 'Rejecting second Godot connection');
236
+ nextSocket.close(SECOND_CONNECTION_CLOSE_CODE, 'Godot already connected');
237
+ return;
238
+ }
239
+ this.socket = nextSocket;
240
+ this.connectionInfo = {
241
+ connectedAt: new Date(),
242
+ };
243
+ this.startKeepalive();
244
+ this.log('info', 'Godot editor connected');
245
+ this.emitBridgeEvent('godot_connected', { projectPath: this.connectionInfo.projectPath });
246
+ nextSocket.on('message', (data) => {
247
+ this.handleRawMessage(data);
248
+ });
249
+ nextSocket.on('close', (code, reasonBuffer) => {
250
+ const reason = reasonBuffer.toString();
251
+ this.log('warn', `Godot disconnected (code=${code}, reason=${reason || 'none'})`);
252
+ this.handleDisconnect(new Error('Godot disconnected during request'));
253
+ });
254
+ nextSocket.on('error', (error) => {
255
+ this.log('error', `WebSocket error: ${error.message}`);
256
+ });
257
+ }
258
+ handleRawMessage(data) {
259
+ let parsed;
260
+ try {
261
+ parsed = JSON.parse(data.toString());
262
+ }
263
+ catch (error) {
264
+ this.log('error', `Invalid JSON from Godot: ${error instanceof Error ? error.message : String(error)}`);
265
+ return;
266
+ }
267
+ if (!this.isIncomingMessage(parsed)) {
268
+ this.log('warn', 'Ignoring unknown Godot message payload');
269
+ return;
270
+ }
271
+ this.handleMessage(parsed);
272
+ }
273
+ handleMessage(message) {
274
+ switch (message.type) {
275
+ case 'tool_result': {
276
+ const pending = this.pendingRequests.get(message.id);
277
+ if (!pending) {
278
+ this.log('warn', `Received tool_result for unknown id=${message.id}`);
279
+ return;
280
+ }
281
+ clearTimeout(pending.timeout);
282
+ this.pendingRequests.delete(message.id);
283
+ const duration = Date.now() - pending.startedAt;
284
+ this.log('debug', `Tool ${pending.toolName} finished in ${duration}ms`);
285
+ this.emitBridgeEvent('tool_end', {
286
+ tool: pending.toolName,
287
+ id: message.id,
288
+ success: message.success,
289
+ duration,
290
+ });
291
+ if (message.success) {
292
+ pending.resolve(message.result);
293
+ }
294
+ else {
295
+ pending.reject(new Error(message.error ?? `Tool ${pending.toolName} failed`));
296
+ }
297
+ return;
298
+ }
299
+ case 'godot_ready':
300
+ if (this.connectionInfo) {
301
+ this.connectionInfo.projectPath = message.project_path;
302
+ this.log('info', `Godot ready: ${message.project_path}`);
303
+ this.emitBridgeEvent('godot_connected', { projectPath: message.project_path });
304
+ }
305
+ return;
306
+ case 'pong':
307
+ if (this.connectionInfo) {
308
+ this.connectionInfo.lastPongAt = new Date();
309
+ }
310
+ return;
311
+ }
312
+ }
313
+ invokeToolDirect(toolName, args, resourceKey) {
314
+ if (!this.isConnected()) {
315
+ return Promise.reject(new Error('Godot is not connected'));
316
+ }
317
+ const requestId = randomUUID();
318
+ const message = {
319
+ type: 'tool_invoke',
320
+ id: requestId,
321
+ tool: toolName,
322
+ args,
323
+ };
324
+ return new Promise((resolve, reject) => {
325
+ const timeout = setTimeout(() => {
326
+ this.pendingRequests.delete(requestId);
327
+ reject(new Error(`Tool ${toolName} timed out after ${this.timeoutMs}ms`));
328
+ }, this.timeoutMs);
329
+ this.pendingRequests.set(requestId, {
330
+ toolName,
331
+ timeout,
332
+ resolve,
333
+ reject,
334
+ startedAt: Date.now(),
335
+ resourceKey,
336
+ });
337
+ this.emitBridgeEvent('tool_start', {
338
+ tool: toolName,
339
+ id: requestId,
340
+ args,
341
+ });
342
+ try {
343
+ this.sendMessage(message);
344
+ }
345
+ catch (error) {
346
+ clearTimeout(timeout);
347
+ this.pendingRequests.delete(requestId);
348
+ reject(error);
349
+ }
350
+ });
351
+ }
352
+ sendMessage(message) {
353
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
354
+ throw new Error('Godot is not connected');
355
+ }
356
+ this.socket.send(JSON.stringify(message));
357
+ }
358
+ startKeepalive() {
359
+ this.stopKeepalive();
360
+ this.pingInterval = setInterval(() => {
361
+ if (!this.isConnected()) {
362
+ return;
363
+ }
364
+ try {
365
+ const ping = { type: 'ping' };
366
+ this.sendMessage(ping);
367
+ }
368
+ catch (error) {
369
+ this.log('warn', `Failed to send ping: ${error instanceof Error ? error.message : String(error)}`);
370
+ }
371
+ }, KEEPALIVE_INTERVAL_MS);
372
+ }
373
+ stopKeepalive() {
374
+ if (!this.pingInterval) {
375
+ return;
376
+ }
377
+ clearInterval(this.pingInterval);
378
+ this.pingInterval = null;
379
+ }
380
+ handleDisconnect(reason) {
381
+ this.stopKeepalive();
382
+ this.socket = null;
383
+ this.connectionInfo = null;
384
+ this.emitBridgeEvent('godot_disconnected', {});
385
+ this.rejectAllPending(reason);
386
+ this.resourceQueues.clear();
387
+ }
388
+ emitBridgeEvent(eventName, payload) {
389
+ this.emit(eventName, payload);
390
+ }
391
+ getDefaultVisualizerHtml() {
392
+ return `<!doctype html>
393
+ <html lang="en">
394
+ <head>
395
+ <meta charset="utf-8" />
396
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
397
+ <title>Godot MCP Visualizer</title>
398
+ </head>
399
+ <body>
400
+ <h1>Godot MCP Visualizer</h1>
401
+ <p>Run the map_project tool to load visualization data.</p>
402
+ </body>
403
+ </html>`;
404
+ }
405
+ rejectAllPending(error) {
406
+ for (const pending of this.pendingRequests.values()) {
407
+ clearTimeout(pending.timeout);
408
+ pending.reject(error);
409
+ }
410
+ this.pendingRequests.clear();
411
+ }
412
+ enqueueResourceRequest(resourceKey, task) {
413
+ const previous = this.resourceQueues.get(resourceKey) ?? Promise.resolve();
414
+ const taskPromise = previous.catch(() => undefined).then(task);
415
+ const tail = taskPromise.then(() => undefined, () => undefined);
416
+ this.resourceQueues.set(resourceKey, tail);
417
+ return taskPromise.finally(() => {
418
+ if (this.resourceQueues.get(resourceKey) === tail) {
419
+ this.resourceQueues.delete(resourceKey);
420
+ }
421
+ });
422
+ }
423
+ getResourceKey(args) {
424
+ const scenePath = this.getStringArg(args, 'scenePath') ?? this.getStringArg(args, 'scene_path');
425
+ if (scenePath) {
426
+ return `scene:${scenePath}`;
427
+ }
428
+ const resourcePath = this.getStringArg(args, 'resourcePath') ?? this.getStringArg(args, 'resource_path');
429
+ if (resourcePath) {
430
+ return `resource:${resourcePath}`;
431
+ }
432
+ return undefined;
433
+ }
434
+ getStringArg(args, key) {
435
+ const value = args[key];
436
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
437
+ }
438
+ isIncomingMessage(value) {
439
+ if (!value || typeof value !== 'object') {
440
+ return false;
441
+ }
442
+ const message = value;
443
+ const type = message.type;
444
+ if (type !== 'tool_result' && type !== 'pong' && type !== 'godot_ready') {
445
+ return false;
446
+ }
447
+ if (type === 'pong') {
448
+ return true;
449
+ }
450
+ if (type === 'godot_ready') {
451
+ return typeof message.project_path === 'string';
452
+ }
453
+ return (typeof message.id === 'string' &&
454
+ typeof message.success === 'boolean' &&
455
+ (message.error === undefined || typeof message.error === 'string'));
456
+ }
457
+ log(level, message) {
458
+ console.error(`[${new Date().toISOString()}] [GodotBridge:${level.toUpperCase()}] ${message}`);
459
+ }
460
+ }
461
+ let defaultBridge = null;
462
+ export function getDefaultBridge() {
463
+ if (!defaultBridge) {
464
+ defaultBridge = new GodotBridge();
465
+ }
466
+ return defaultBridge;
467
+ }
468
+ export function createBridge(port, timeoutMs) {
469
+ return new GodotBridge(port, timeoutMs);
470
+ }