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