agent-gear 1.1.1 → 1.1.4

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.
Files changed (50) hide show
  1. package/AGENTS.md +33 -0
  2. package/dist/acp/bridge.js +2 -2
  3. package/dist/acp/bridge.js.map +1 -1
  4. package/dist/acp/detector.d.ts +3 -0
  5. package/dist/acp/detector.d.ts.map +1 -1
  6. package/dist/acp/detector.js +41 -2
  7. package/dist/acp/detector.js.map +1 -1
  8. package/dist/commands/mcp-proxy.d.ts +8 -0
  9. package/dist/commands/mcp-proxy.d.ts.map +1 -0
  10. package/dist/commands/mcp-proxy.js +105 -0
  11. package/dist/commands/mcp-proxy.js.map +1 -0
  12. package/dist/commands/mcp.d.ts +7 -0
  13. package/dist/commands/mcp.d.ts.map +1 -0
  14. package/dist/commands/mcp.js +33 -0
  15. package/dist/commands/mcp.js.map +1 -0
  16. package/dist/{port-forward.d.ts → gateway.d.ts} +7 -3
  17. package/dist/gateway.d.ts.map +1 -0
  18. package/dist/gateway.js +158 -0
  19. package/dist/gateway.js.map +1 -0
  20. package/dist/gateway.test.d.ts +2 -0
  21. package/dist/gateway.test.d.ts.map +1 -0
  22. package/dist/gateway.test.js +241 -0
  23. package/dist/gateway.test.js.map +1 -0
  24. package/dist/index.js +26 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/socketio-client.d.ts.map +1 -1
  27. package/dist/socketio-client.js +20 -20
  28. package/dist/socketio-client.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/acp/bridge.ts +4 -4
  31. package/src/acp/detector.ts +41 -2
  32. package/src/commands/mcp-proxy.ts +128 -0
  33. package/src/commands/mcp.ts +41 -0
  34. package/src/gateway.test.ts +301 -0
  35. package/src/gateway.ts +195 -0
  36. package/src/index.ts +28 -0
  37. package/src/socketio-client.ts +22 -16
  38. package/tests/acp-bridge.test.ts +6 -6
  39. package/tests/acp-detector.test.ts +68 -26
  40. package/tests/commands/mcp-proxy.test.ts +111 -0
  41. package/tests/commands/mcp.test.ts +43 -0
  42. package/dist/port-forward.d.ts.map +0 -1
  43. package/dist/port-forward.js +0 -68
  44. package/dist/port-forward.js.map +0 -1
  45. package/dist/port-forward.test.d.ts +0 -2
  46. package/dist/port-forward.test.d.ts.map +0 -1
  47. package/dist/port-forward.test.js +0 -163
  48. package/dist/port-forward.test.js.map +0 -1
  49. package/src/port-forward.test.ts +0 -202
  50. package/src/port-forward.ts +0 -88
@@ -0,0 +1,128 @@
1
+ import { spawn, ChildProcess } from 'child_process';
2
+ import { io as ioClient, Socket } from 'socket.io-client';
3
+
4
+ interface McpProxyOptions {
5
+ server: string;
6
+ key: string;
7
+ command: string;
8
+ }
9
+
10
+ export async function startMcpRegistration(options: McpProxyOptions): Promise<void> {
11
+ const { server, key, command } = options;
12
+
13
+ console.log(`Registering MCP proxy with server ${server}...`);
14
+
15
+ // 1. Register with server
16
+ const response = await fetch(`${server}/api/mcp-proxy/register`, {
17
+ method: 'POST',
18
+ headers: { 'Content-Type': 'application/json' },
19
+ body: JSON.stringify({
20
+ key,
21
+ hostname: process.env.HOSTNAME || 'unknown',
22
+ command,
23
+ serverName: command.split(' ').pop() || 'unknown',
24
+ }),
25
+ });
26
+
27
+ const result = await response.json() as any;
28
+ if (!result.success) {
29
+ console.error('Registration failed:', result.error);
30
+ process.exit(1);
31
+ }
32
+
33
+ console.log(`Registered as proxy ${result.proxyId}, server ${result.serverId}`);
34
+
35
+ // 2. Spawn local command
36
+ const [cmd, ...args] = command.split(' ');
37
+ console.log(`Starting local MCP command: ${cmd} ${args.join(' ')}`);
38
+
39
+ const child = spawn(cmd, args, {
40
+ stdio: ['pipe', 'pipe', 'pipe'],
41
+ shell: true,
42
+ });
43
+
44
+ child.on('error', (err) => {
45
+ console.error('Failed to start MCP command:', err.message);
46
+ });
47
+
48
+ child.on('exit', (code) => {
49
+ console.log(`MCP command exited with code ${code}`);
50
+ });
51
+
52
+ // 3. Establish WebSocket connection
53
+ const socket = ioClient(`${server}/mcp-proxy`, {
54
+ transports: ['websocket'],
55
+ autoConnect: true,
56
+ });
57
+
58
+ socket.on('connect', () => {
59
+ console.log('WebSocket connected to proxy namespace');
60
+ socket.emit('proxy:register', { proxyId: result.proxyId, key });
61
+ });
62
+
63
+ socket.on('proxy:registered', (data: { proxyId: string; serverId: string }) => {
64
+ console.log(`Registered with proxy namespace, serverId: ${data.serverId}`);
65
+ });
66
+
67
+ socket.on('proxy:error', (data: { code: string; message: string }) => {
68
+ console.error(`Proxy error: ${data.code} - ${data.message}`);
69
+ });
70
+
71
+ // 4. Bridge requests
72
+ socket.on('proxy:request', async (data: { requestId: string; request: any }) => {
73
+ try {
74
+ const response = await forwardToChild(child, data.request);
75
+ socket.emit('proxy:response', { requestId: data.requestId, response });
76
+ } catch (err: any) {
77
+ socket.emit('proxy:response', {
78
+ requestId: data.requestId,
79
+ response: {
80
+ jsonrpc: '2.0',
81
+ id: data.request.id,
82
+ error: { code: -32603, message: err.message },
83
+ },
84
+ });
85
+ }
86
+ });
87
+
88
+ // 5. Heartbeat
89
+ const heartbeatInterval = setInterval(() => {
90
+ if (socket.connected) {
91
+ socket.emit('proxy:heartbeat', { proxyId: result.proxyId });
92
+ }
93
+ }, 30000);
94
+
95
+ // 6. Cleanup
96
+ const shutdown = () => {
97
+ console.log('Shutting down MCP proxy...');
98
+ clearInterval(heartbeatInterval);
99
+ socket.emit('proxy:unregister', { proxyId: result.proxyId });
100
+ child.kill();
101
+ socket.disconnect();
102
+ process.exit(0);
103
+ };
104
+
105
+ process.on('SIGINT', shutdown);
106
+ process.on('SIGTERM', shutdown);
107
+ }
108
+
109
+ function forwardToChild(child: ChildProcess, request: any): Promise<any> {
110
+ return new Promise((resolve, reject) => {
111
+ const timeout = setTimeout(() => {
112
+ reject(new Error('Request timeout'));
113
+ }, 30000);
114
+
115
+ const onData = (data: Buffer) => {
116
+ clearTimeout(timeout);
117
+ child.stdout?.removeListener('data', onData);
118
+ try {
119
+ resolve(JSON.parse(data.toString()));
120
+ } catch {
121
+ reject(new Error('Invalid JSON response from MCP command'));
122
+ }
123
+ };
124
+
125
+ child.stdout?.on('data', onData);
126
+ child.stdin?.write(JSON.stringify(request) + '\n');
127
+ });
128
+ }
@@ -0,0 +1,41 @@
1
+ import * as readline from 'readline';
2
+
3
+ interface McpProxyOptions {
4
+ endpoint: string;
5
+ token: string;
6
+ }
7
+
8
+ export async function startMcpProxy(options: McpProxyOptions): Promise<void> {
9
+ const { endpoint, token } = options;
10
+
11
+ console.log(`Starting MCP proxy for endpoint: ${endpoint}`);
12
+
13
+ const rl = readline.createInterface({ input: process.stdin });
14
+
15
+ rl.on('line', async (line) => {
16
+ try {
17
+ const request = JSON.parse(line);
18
+ const response = await fetch(endpoint, {
19
+ method: 'POST',
20
+ headers: {
21
+ 'Content-Type': 'application/json',
22
+ 'Authorization': `Bearer ${token}`,
23
+ },
24
+ body: JSON.stringify(request),
25
+ });
26
+ const result = await response.json();
27
+ process.stdout.write(JSON.stringify(result) + '\n');
28
+ } catch (err: any) {
29
+ const error = {
30
+ jsonrpc: '2.0',
31
+ id: null,
32
+ error: { code: -32603, message: err.message },
33
+ };
34
+ process.stdout.write(JSON.stringify(error) + '\n');
35
+ }
36
+ });
37
+
38
+ rl.on('close', () => {
39
+ process.exit(0);
40
+ });
41
+ }
@@ -0,0 +1,301 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ vi.mock('node:net', () => ({
4
+ default: {
5
+ connect: vi.fn(),
6
+ },
7
+ }));
8
+
9
+ import net from 'node:net';
10
+ import { GatewayManager } from './gateway.js';
11
+
12
+ function createMockLocalSocket() {
13
+ const handlers = new Map<string, Function>();
14
+ return {
15
+ once: vi.fn((event: string, handler: Function) => {
16
+ handlers.set(event, handler);
17
+ }),
18
+ on: vi.fn((event: string, handler: Function) => {
19
+ handlers.set(event, handler);
20
+ }),
21
+ removeListener: vi.fn(),
22
+ destroy: vi.fn(),
23
+ write: vi.fn(),
24
+ emit: vi.fn(),
25
+ setKeepAlive: vi.fn(),
26
+ _handlers: handlers,
27
+ _trigger(event: string, ...args: unknown[]) {
28
+ const handler = handlers.get(event);
29
+ if (handler) handler(...args);
30
+ },
31
+ };
32
+ }
33
+
34
+ function createMockServerSocket() {
35
+ return {
36
+ emit: vi.fn(),
37
+ };
38
+ }
39
+
40
+ describe('GatewayManager', () => {
41
+ let gatewayManager: GatewayManager;
42
+
43
+ beforeEach(() => {
44
+ vi.clearAllMocks();
45
+ gatewayManager = new GatewayManager();
46
+ });
47
+
48
+ describe('createTunnel', () => {
49
+ it('should send port-forward:created(success: true) and store tunnel on connection success', async () => {
50
+ const mockLocalSocket = createMockLocalSocket();
51
+ const mockServerSocket = createMockServerSocket();
52
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
53
+
54
+ const promise = gatewayManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
55
+ mockLocalSocket._trigger('connect');
56
+ await promise;
57
+
58
+ expect(net.connect).toHaveBeenCalledWith({ host: '127.0.0.1', port: 3000 });
59
+ expect(mockLocalSocket.setKeepAlive).toHaveBeenCalledWith(true, 5000);
60
+ expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:created', {
61
+ tunnelId: 'tunnel-1',
62
+ success: true,
63
+ });
64
+ });
65
+
66
+ it('should destroy socket and schedule reconnect on connection failure (not emit failure to server)', async () => {
67
+ const mockLocalSocket = createMockLocalSocket();
68
+ const mockServerSocket = createMockServerSocket();
69
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
70
+
71
+ const promise = gatewayManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
72
+ mockLocalSocket._trigger('error', new Error('ECONNREFUSED'));
73
+ await promise;
74
+
75
+ expect(mockServerSocket.emit).not.toHaveBeenCalled();
76
+ expect(mockLocalSocket.destroy).toHaveBeenCalled();
77
+ });
78
+
79
+ it('should send port-forward:data with base64 encoded data when local socket receives data', async () => {
80
+ const mockLocalSocket = createMockLocalSocket();
81
+ const mockServerSocket = createMockServerSocket();
82
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
83
+
84
+ const promise = gatewayManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
85
+ mockLocalSocket._trigger('connect');
86
+ await promise;
87
+
88
+ const testData = Buffer.from('hello world');
89
+ mockLocalSocket._trigger('data', testData);
90
+
91
+ expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:data', {
92
+ tunnelId: 'tunnel-1',
93
+ data: testData.toString('base64'),
94
+ });
95
+ });
96
+
97
+ it('should schedule reconnect (not emit close) when local socket closes', async () => {
98
+ const mockLocalSocket = createMockLocalSocket();
99
+ const mockServerSocket = createMockServerSocket();
100
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
101
+
102
+ const promise = gatewayManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
103
+ mockLocalSocket._trigger('connect');
104
+ await promise;
105
+
106
+ mockLocalSocket._trigger('close');
107
+
108
+ expect(mockServerSocket.emit).not.toHaveBeenCalledWith('port-forward:close', expect.anything());
109
+ });
110
+
111
+ it('should send port-forward:error on local socket error after connect', async () => {
112
+ const mockLocalSocket = createMockLocalSocket();
113
+ const mockServerSocket = createMockServerSocket();
114
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
115
+
116
+ const promise = gatewayManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
117
+ mockLocalSocket._trigger('connect');
118
+ await promise;
119
+
120
+ mockLocalSocket._trigger('error', new Error('ECONNRESET'));
121
+
122
+ expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:error', {
123
+ tunnelId: 'tunnel-1',
124
+ error: 'ECONNRESET',
125
+ });
126
+ });
127
+ });
128
+
129
+ describe('handleTunnelData', () => {
130
+ it('should write base64 decoded data to the corresponding local socket', async () => {
131
+ const mockLocalSocket = createMockLocalSocket();
132
+ const mockServerSocket = createMockServerSocket();
133
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
134
+
135
+ const promise = gatewayManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
136
+ mockLocalSocket._trigger('connect');
137
+ await promise;
138
+
139
+ const base64Data = Buffer.from('test payload').toString('base64');
140
+ gatewayManager.handleTunnelData('tunnel-1', base64Data);
141
+
142
+ expect(mockLocalSocket.write).toHaveBeenCalledWith(Buffer.from('test payload'));
143
+ });
144
+
145
+ it('should parse http-request JSON and write raw HTTP request to local socket', async () => {
146
+ const mockLocalSocket = createMockLocalSocket();
147
+ const mockServerSocket = createMockServerSocket();
148
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
149
+
150
+ const promise = gatewayManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
151
+ mockLocalSocket._trigger('connect');
152
+ await promise;
153
+
154
+ const httpRequest = {
155
+ type: 'http-request',
156
+ requestId: 'req-1',
157
+ method: 'POST',
158
+ path: '/api/test',
159
+ headers: { 'content-type': 'application/json', 'x-custom': 'val' },
160
+ body: Buffer.from('{"key":"value"}').toString('base64'),
161
+ };
162
+ const base64Data = Buffer.from(JSON.stringify(httpRequest)).toString('base64');
163
+ gatewayManager.handleTunnelData('tunnel-1', base64Data);
164
+
165
+ const expected = 'POST /api/test HTTP/1.1\r\n' +
166
+ 'content-type: application/json\r\n' +
167
+ 'x-custom: val\r\n' +
168
+ 'Host: 127.0.0.1:3000\r\n' +
169
+ 'Connection: close\r\n' +
170
+ '\r\n' +
171
+ '{"key":"value"}';
172
+
173
+ expect(mockLocalSocket.write).toHaveBeenCalledWith(expected);
174
+ });
175
+
176
+ it('should send raw base64 TCP response when no pending http-request', async () => {
177
+ const mockLocalSocket = createMockLocalSocket();
178
+ const mockServerSocket = createMockServerSocket();
179
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
180
+
181
+ const promise = gatewayManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
182
+ mockLocalSocket._trigger('connect');
183
+ await promise;
184
+
185
+ const testData = Buffer.from('HTTP/1.1 200 OK\r\n\r\nok');
186
+ mockLocalSocket._trigger('data', testData);
187
+
188
+ expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:data', {
189
+ tunnelId: 'tunnel-1',
190
+ data: testData.toString('base64'),
191
+ });
192
+ });
193
+
194
+ it('should wrap TCP response in http-response JSON when pendingRequestId is set', async () => {
195
+ vi.useFakeTimers();
196
+
197
+ const mockLocalSocket = createMockLocalSocket();
198
+ const mockServerSocket = createMockServerSocket();
199
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
200
+
201
+ const promise = gatewayManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
202
+ mockLocalSocket._trigger('connect');
203
+ await promise;
204
+
205
+ const httpRequest = {
206
+ type: 'http-request',
207
+ requestId: 'req-42',
208
+ method: 'GET',
209
+ path: '/',
210
+ headers: {},
211
+ };
212
+ gatewayManager.handleTunnelData('tunnel-1', Buffer.from(JSON.stringify(httpRequest)).toString('base64'));
213
+
214
+ mockLocalSocket.write.mockClear();
215
+ mockServerSocket.emit.mockClear();
216
+
217
+ const tcpResponse = Buffer.from('HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok');
218
+ mockLocalSocket._trigger('data', tcpResponse);
219
+
220
+ expect(mockServerSocket.emit).not.toHaveBeenCalled();
221
+
222
+ vi.advanceTimersByTime(150);
223
+
224
+ expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:data', expect.objectContaining({
225
+ tunnelId: 'tunnel-1',
226
+ }));
227
+ const emitArg = mockServerSocket.emit.mock.calls.find(
228
+ (c: any[]) => c[0] === 'port-forward:data',
229
+ )?.[1];
230
+ expect(emitArg).toBeDefined();
231
+ const decoded = JSON.parse(Buffer.from(emitArg.data as string, 'base64').toString('utf8'));
232
+ expect(decoded.type).toBe('http-response');
233
+ expect(decoded.requestId).toBe('req-42');
234
+ expect(decoded.body).toBe(tcpResponse.toString('base64'));
235
+
236
+ vi.useRealTimers();
237
+ });
238
+
239
+ it('should log warning and ignore for unknown tunnelId', () => {
240
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
241
+ gatewayManager.handleTunnelData('unknown-tunnel', 'data');
242
+ expect(warnSpy).toHaveBeenCalledWith(
243
+ expect.stringContaining('unknown tunnel'),
244
+ );
245
+ warnSpy.mockRestore();
246
+ });
247
+ });
248
+
249
+ describe('closeTunnel', () => {
250
+ it('should destroy local socket, emit close, and remove tunnel from map', async () => {
251
+ const mockLocalSocket = createMockLocalSocket();
252
+ const mockServerSocket = createMockServerSocket();
253
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
254
+
255
+ const promise = gatewayManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
256
+ mockLocalSocket._trigger('connect');
257
+ await promise;
258
+
259
+ gatewayManager.closeTunnel('tunnel-1');
260
+
261
+ expect(mockLocalSocket.destroy).toHaveBeenCalled();
262
+ expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:close', {
263
+ tunnelId: 'tunnel-1',
264
+ });
265
+ });
266
+
267
+ it('should silently ignore unknown tunnelId', () => {
268
+ expect(() => gatewayManager.closeTunnel('unknown-tunnel')).not.toThrow();
269
+ });
270
+ });
271
+
272
+ describe('closeAll', () => {
273
+ it('should close all tunnels, emit close events, and clear the map', async () => {
274
+ const socket1 = createMockLocalSocket();
275
+ const socket2 = createMockLocalSocket();
276
+ const mockServerSocket = createMockServerSocket();
277
+ vi.mocked(net.connect)
278
+ .mockReturnValueOnce(socket1 as never)
279
+ .mockReturnValueOnce(socket2 as never);
280
+
281
+ const promise1 = gatewayManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
282
+ socket1._trigger('connect');
283
+ await promise1;
284
+
285
+ const promise2 = gatewayManager.createTunnel('tunnel-2', 4000, mockServerSocket as never);
286
+ socket2._trigger('connect');
287
+ await promise2;
288
+
289
+ gatewayManager.closeAll();
290
+
291
+ expect(socket1.destroy).toHaveBeenCalled();
292
+ expect(socket2.destroy).toHaveBeenCalled();
293
+ expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:close', {
294
+ tunnelId: 'tunnel-1',
295
+ });
296
+ expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:close', {
297
+ tunnelId: 'tunnel-2',
298
+ });
299
+ });
300
+ });
301
+ });
package/src/gateway.ts ADDED
@@ -0,0 +1,195 @@
1
+ import net from 'node:net';
2
+ import type { Socket } from 'socket.io-client';
3
+
4
+ interface Tunnel {
5
+ id: string;
6
+ port: number;
7
+ localSocket: net.Socket | null;
8
+ serverSocket: Socket;
9
+ pendingRequestId?: string | null;
10
+ responseBuffer: Buffer[];
11
+ responseTimer?: ReturnType<typeof setTimeout> | null;
12
+ }
13
+
14
+ const RECONNECT_INTERVAL_MS = 3000;
15
+ const HTTP_RESPONSE_COLLECT_MS = 100;
16
+
17
+ export class GatewayManager {
18
+ private tunnels: Map<string, Tunnel> = new Map();
19
+ private reconnectTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
20
+
21
+ async createTunnel(tunnelId: string, port: number, serverSocket: Socket): Promise<void> {
22
+ this.tunnels.set(tunnelId, { id: tunnelId, port, localSocket: null, serverSocket, responseBuffer: [] });
23
+ await this.connect(tunnelId, true);
24
+ }
25
+
26
+ private async connect(tunnelId: string, isInitial: boolean): Promise<void> {
27
+ const tunnel = this.tunnels.get(tunnelId);
28
+ if (!tunnel) return;
29
+
30
+ const localSocket = net.connect({ host: '127.0.0.1', port: tunnel.port });
31
+ localSocket.setKeepAlive(true, 5000);
32
+
33
+ return new Promise<void>((resolve) => {
34
+ const onError = (err: Error) => {
35
+ console.error(`Gateway tunnel error: tunnelId=${tunnelId} error=${err.message}`);
36
+ localSocket.destroy();
37
+ this.scheduleReconnect(tunnelId);
38
+ resolve();
39
+ };
40
+
41
+ localSocket.once('error', onError);
42
+
43
+ localSocket.once('connect', () => {
44
+ localSocket.removeListener('error', onError);
45
+
46
+ tunnel.localSocket = localSocket;
47
+ console.log(`Gateway tunnel connected: tunnelId=${tunnelId} port=${tunnel.port}`);
48
+
49
+ if (isInitial) {
50
+ tunnel.serverSocket.emit('port-forward:created', { tunnelId, success: true });
51
+ }
52
+
53
+ localSocket.on('data', (data: Buffer) => {
54
+ if (tunnel.pendingRequestId) {
55
+ tunnel.responseBuffer.push(data);
56
+ if (tunnel.responseTimer) clearTimeout(tunnel.responseTimer);
57
+ tunnel.responseTimer = setTimeout(() => this.flushResponse(tunnel), HTTP_RESPONSE_COLLECT_MS);
58
+ } else {
59
+ tunnel.serverSocket.emit('port-forward:data', {
60
+ tunnelId,
61
+ data: data.toString('base64'),
62
+ });
63
+ }
64
+ });
65
+
66
+ localSocket.on('close', () => {
67
+ console.log(`Gateway local socket closed: tunnelId=${tunnelId}`);
68
+ tunnel.localSocket = null;
69
+ this.scheduleReconnect(tunnelId);
70
+ });
71
+
72
+ localSocket.on('error', (err: Error) => {
73
+ console.error(`Gateway local socket error: tunnelId=${tunnelId} error=${err.message}`);
74
+ tunnel.serverSocket.emit('port-forward:error', { tunnelId, error: err.message });
75
+ });
76
+
77
+ resolve();
78
+ });
79
+ });
80
+ }
81
+
82
+ private flushResponse(tunnel: Tunnel): void {
83
+ if (!tunnel.pendingRequestId) return;
84
+ const combined = Buffer.concat(tunnel.responseBuffer);
85
+ tunnel.responseBuffer = [];
86
+ const requestId = tunnel.pendingRequestId;
87
+ tunnel.pendingRequestId = null;
88
+ tunnel.responseTimer = null;
89
+
90
+ tunnel.serverSocket.emit('port-forward:data', {
91
+ tunnelId: tunnel.id,
92
+ data: Buffer.from(JSON.stringify({
93
+ type: 'http-response',
94
+ requestId,
95
+ body: combined.toString('base64'),
96
+ })).toString('base64'),
97
+ });
98
+ }
99
+
100
+ private scheduleReconnect(tunnelId: string): void {
101
+ if (!this.tunnels.has(tunnelId) || this.reconnectTimers.has(tunnelId)) return;
102
+
103
+ console.log(`Gateway scheduling reconnection: tunnelId=${tunnelId}`);
104
+ const timer = setTimeout(() => {
105
+ this.reconnectTimers.delete(tunnelId);
106
+ this.connect(tunnelId, false);
107
+ }, RECONNECT_INTERVAL_MS);
108
+
109
+ this.reconnectTimers.set(tunnelId, timer);
110
+ }
111
+
112
+ handleTunnelData(tunnelId: string, data: string): void {
113
+ const tunnel = this.tunnels.get(tunnelId);
114
+ if (!tunnel) {
115
+ console.warn(`Gateway data for unknown tunnel: tunnelId=${tunnelId}`);
116
+ return;
117
+ }
118
+
119
+ const buffer = Buffer.from(data, 'base64');
120
+ let parsed: any;
121
+ try {
122
+ parsed = JSON.parse(buffer.toString('utf8'));
123
+ } catch {
124
+ if (tunnel.localSocket) {
125
+ tunnel.localSocket.write(buffer);
126
+ }
127
+ return;
128
+ }
129
+
130
+ if (parsed.type === 'http-request') {
131
+ const { method, path, headers, body } = parsed;
132
+ let rawRequest = `${method} ${path} HTTP/1.1\r\n`;
133
+ for (const [key, value] of Object.entries(headers || {})) {
134
+ rawRequest += `${key}: ${value}\r\n`;
135
+ }
136
+ rawRequest += `Host: 127.0.0.1:${tunnel.port}\r\n`;
137
+ rawRequest += 'Connection: close\r\n';
138
+ rawRequest += '\r\n';
139
+ if (body) rawRequest += Buffer.from(body, 'base64').toString('utf8');
140
+
141
+ tunnel.pendingRequestId = parsed.requestId;
142
+ tunnel.responseBuffer = [];
143
+
144
+ if (tunnel.localSocket) {
145
+ tunnel.localSocket.write(rawRequest);
146
+ }
147
+ return;
148
+ }
149
+
150
+ if (tunnel.localSocket) {
151
+ tunnel.localSocket.write(buffer);
152
+ }
153
+ }
154
+
155
+ closeTunnel(tunnelId: string): void {
156
+ const tunnel = this.tunnels.get(tunnelId);
157
+ if (!tunnel) return;
158
+
159
+ console.log(`Gateway closing tunnel: tunnelId=${tunnelId}`);
160
+
161
+ const timer = this.reconnectTimers.get(tunnelId);
162
+ if (timer) {
163
+ clearTimeout(timer);
164
+ this.reconnectTimers.delete(tunnelId);
165
+ }
166
+
167
+ this.tunnels.delete(tunnelId);
168
+
169
+ if (tunnel.localSocket) {
170
+ tunnel.localSocket.destroy();
171
+ }
172
+ tunnel.serverSocket.emit('port-forward:close', { tunnelId });
173
+ }
174
+
175
+ closeAll(): void {
176
+ console.log(`Gateway closing all tunnels: count=${this.tunnels.size}`);
177
+
178
+ for (const [tunnelId, tunnel] of [...this.tunnels]) {
179
+ const timer = this.reconnectTimers.get(tunnelId);
180
+ if (timer) {
181
+ clearTimeout(timer);
182
+ this.reconnectTimers.delete(tunnelId);
183
+ }
184
+
185
+ this.tunnels.delete(tunnelId);
186
+
187
+ if (tunnel.localSocket) {
188
+ tunnel.localSocket.destroy();
189
+ }
190
+ tunnel.serverSocket.emit('port-forward:close', { tunnelId: tunnel.id });
191
+ }
192
+ }
193
+ }
194
+
195
+ export const gatewayManager = new GatewayManager();
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ import { hostname } from 'os';
4
4
  import { resolveKey, resolveEndpoint, saveKeyToConfig, saveEndpointToConfig } from './config.js';
5
5
  import { connectToServer } from './socketio-client.js';
6
6
  import { writePid, removePid, isRunning } from './daemon.js';
7
+ import { startMcpRegistration } from './commands/mcp-proxy.js';
8
+ import { startMcpProxy } from './commands/mcp.js';
7
9
 
8
10
  const program = new Command();
9
11
 
@@ -56,4 +58,30 @@ program
56
58
  });
57
59
  });
58
60
 
61
+ program
62
+ .command('mcp-proxy')
63
+ .description('Register a local MCP command as a proxy')
64
+ .requiredOption('--mcp-server <endpoint>', 'Server endpoint URL')
65
+ .requiredOption('--mcp-key <key>', 'MCP Proxy Key')
66
+ .requiredOption('--command <command>', 'Local MCP command to proxy')
67
+ .action(async (options) => {
68
+ await startMcpRegistration({
69
+ server: options.mcpServer,
70
+ key: options.mcpKey,
71
+ command: options.command,
72
+ });
73
+ });
74
+
75
+ program
76
+ .command('mcp')
77
+ .description('Start stdio MCP proxy (HTTP to stdio)')
78
+ .requiredOption('--endpoint <url>', 'MCP forwarder endpoint URL')
79
+ .requiredOption('--token <token>', 'Bearer token for authentication')
80
+ .action(async (options) => {
81
+ await startMcpProxy({
82
+ endpoint: options.endpoint,
83
+ token: options.token,
84
+ });
85
+ });
86
+
59
87
  program.parse();