mcp-local-tunnel 1.0.1 → 1.1.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 CHANGED
@@ -81,6 +81,8 @@ On first run, the agent opens your browser for login (same identity provider as
81
81
 
82
82
  The agent spawns the configured local MCP servers, connects to the relay, and registers all their tools. Remote clients see tools like `shell__execute` and `computer__computer` — namespaced by server name.
83
83
 
84
+ When used as an MCP server in tools like Claude Code or opencode, the agent runs as a background daemon. Multiple sessions share a single relay connection — the daemon stays alive as long as at least one session exists, and shuts down automatically after the last session ends. Daemon logs are written to `~/.config/mcp-local-tunnel/logs/`.
85
+
84
86
  If the agent disconnects (laptop sleeps, network drops), it automatically reconnects with exponential backoff.
85
87
 
86
88
  ### Config
package/dist/agent.d.ts CHANGED
@@ -1,2 +1,6 @@
1
1
  import type { AgentConfig } from './types.js';
2
- export declare const startAgent: (config: AgentConfig) => Promise<void>;
2
+ export type AgentHandle = {
3
+ /** Gracefully shut down the agent, closing all connections and servers. */
4
+ stop: () => Promise<void>;
5
+ };
6
+ export declare const startAgent: (config: AgentConfig) => Promise<AgentHandle>;
package/dist/agent.js CHANGED
@@ -12,6 +12,8 @@ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/client/streamable
12
12
  const agent_auth_js_1 = require("./agent-auth.js");
13
13
  const MAX_RECONNECT_DELAY_MS = 30_000;
14
14
  const INITIAL_RECONNECT_DELAY_MS = 1_000;
15
+ const PING_INTERVAL_MS = 30_000;
16
+ const PONG_TIMEOUT_MS = 10_000;
15
17
  const toWsUrl = (relay) => {
16
18
  if (relay.startsWith('ws://') || relay.startsWith('wss://')) {
17
19
  return relay.endsWith('/ws') ? relay : `${relay.replace(/\/$/, '')}/ws`;
@@ -29,6 +31,8 @@ const startAgent = async (config) => {
29
31
  const relayUrl = toWsUrl(config.relay);
30
32
  let reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
31
33
  let shouldReconnect = true;
34
+ let currentWs;
35
+ let currentServers = [];
32
36
  const spawnServers = async () => {
33
37
  const servers = [];
34
38
  const entries = Object.entries(config.servers);
@@ -117,12 +121,33 @@ const startAgent = async (config) => {
117
121
  wsUrl.searchParams.set('token', token);
118
122
  console.log(`Connecting to ${config.relay} as "${name}"...`);
119
123
  const ws = new ws_1.default(wsUrl.toString());
124
+ let pingInterval;
125
+ let pongTimeout;
120
126
  ws.on('open', () => {
121
127
  reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
122
128
  console.log(`Connected to ${config.relay} as "${name}"`);
123
129
  console.log(`Serving ${tools.length} tools: ${tools.map((t) => t.name).join(', ')}`);
124
130
  const msg = { type: 'tools', tools };
125
131
  ws.send(JSON.stringify(msg));
132
+ // Start ping/pong keepalive
133
+ pingInterval = setInterval(() => {
134
+ if (ws.readyState === ws_1.default.OPEN) {
135
+ ws.ping();
136
+ if (pongTimeout) {
137
+ clearTimeout(pongTimeout);
138
+ }
139
+ pongTimeout = setTimeout(() => {
140
+ console.log('Pong timeout — connection dead, terminating.');
141
+ ws.terminate();
142
+ }, PONG_TIMEOUT_MS);
143
+ }
144
+ }, PING_INTERVAL_MS);
145
+ });
146
+ ws.on('pong', () => {
147
+ if (pongTimeout) {
148
+ clearTimeout(pongTimeout);
149
+ pongTimeout = undefined;
150
+ }
126
151
  });
127
152
  ws.on('message', async (data) => {
128
153
  try {
@@ -157,6 +182,14 @@ const startAgent = async (config) => {
157
182
  }
158
183
  });
159
184
  ws.on('close', () => {
185
+ if (pingInterval) {
186
+ clearInterval(pingInterval);
187
+ pingInterval = undefined;
188
+ }
189
+ if (pongTimeout) {
190
+ clearTimeout(pongTimeout);
191
+ pongTimeout = undefined;
192
+ }
160
193
  console.log('Disconnected from relay.');
161
194
  void closeServers(servers);
162
195
  if (shouldReconnect) {
@@ -170,15 +203,17 @@ const startAgent = async (config) => {
170
203
  ws.on('error', (err) => {
171
204
  console.error('WebSocket error:', err.message);
172
205
  });
173
- const shutdown = () => {
206
+ currentWs = ws;
207
+ currentServers = servers;
208
+ };
209
+ await connect();
210
+ return {
211
+ async stop() {
174
212
  shouldReconnect = false;
175
213
  console.log('\nShutting down agent...');
176
- ws.close();
177
- void closeServers(servers).then(() => process.exit(0));
178
- };
179
- process.on('SIGINT', shutdown);
180
- process.on('SIGTERM', shutdown);
214
+ currentWs?.close();
215
+ await closeServers(currentServers);
216
+ },
181
217
  };
182
- await connect();
183
218
  };
184
219
  exports.startAgent = startAgent;
@@ -6,11 +6,13 @@ export type AgentConnection = {
6
6
  name: string;
7
7
  connectedAt: number;
8
8
  tools: ToolDef[];
9
+ lastPingAt: number;
9
10
  };
10
11
  export declare class ConnectionManager {
11
12
  /** userId -> AgentConnection[] (one per device) */
12
13
  private readonly connections;
13
14
  private readonly pending;
15
+ constructor();
14
16
  addConnection(ws: WebSocket, userId: string, name: string): AgentConnection;
15
17
  getConnectionsForUser(userId: string): AgentConnection[];
16
18
  getToolsForUser(userId: string): (ToolDef & {
@@ -3,13 +3,31 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ConnectionManager = void 0;
4
4
  const node_crypto_1 = require("node:crypto");
5
5
  const REQUEST_TIMEOUT_MS = 120_000; // 2 minutes
6
+ const STALE_CHECK_INTERVAL_MS = 60_000;
7
+ const STALE_THRESHOLD_MS = 90_000; // 3 missed 30s pings
6
8
  class ConnectionManager {
7
9
  /** userId -> AgentConnection[] (one per device) */
8
10
  connections = new Map();
9
11
  pending = new Map();
12
+ constructor() {
13
+ // Periodically terminate connections that haven't pinged recently
14
+ const interval = setInterval(() => {
15
+ const threshold = Date.now() - STALE_THRESHOLD_MS;
16
+ for (const conns of this.connections.values()) {
17
+ for (const conn of conns) {
18
+ if (conn.lastPingAt < threshold) {
19
+ console.log(`No ping from "${conn.name}" (${conn.userId}) for ${Math.round((Date.now() - conn.lastPingAt) / 1000)}s, terminating.`);
20
+ conn.ws.terminate();
21
+ }
22
+ }
23
+ }
24
+ }, STALE_CHECK_INTERVAL_MS);
25
+ interval.unref();
26
+ }
10
27
  addConnection(ws, userId, name) {
28
+ const now = Date.now();
11
29
  const conn = {
12
- ws, userId, name, connectedAt: Date.now(), tools: [],
30
+ ws, userId, name, connectedAt: now, tools: [], lastPingAt: now,
13
31
  };
14
32
  ws.on('message', (data) => {
15
33
  try {
@@ -20,6 +38,10 @@ class ConnectionManager {
20
38
  console.error('Failed to parse WebSocket message:', err);
21
39
  }
22
40
  });
41
+ // Track agent pings as liveness signal
42
+ ws.on('ping', () => {
43
+ conn.lastPingAt = Date.now();
44
+ });
23
45
  ws.on('close', () => {
24
46
  this.removeConnection(conn);
25
47
  });
@@ -0,0 +1,18 @@
1
+ import { type AgentHandle } from './agent.js';
2
+ import type { AgentConfig } from './types.js';
3
+ export type DaemonOptions = {
4
+ /** Override how the agent is started (used in tests). */
5
+ startAgent?: (config: AgentConfig) => Promise<AgentHandle>;
6
+ /** Override the grace period in ms before shutdown after last session disconnects. */
7
+ gracePeriodMs?: number;
8
+ /** Override the PID check interval in ms. */
9
+ pidCheckIntervalMs?: number;
10
+ };
11
+ /**
12
+ * Start the daemon process. Runs the agent and listens on an IPC socket
13
+ * for session connections from stub servers. Shuts down when no sessions
14
+ * remain after a grace period.
15
+ *
16
+ * This is invoked internally and should not be called directly by users.
17
+ */
18
+ export declare const startDaemon: (config: AgentConfig, configIdentifier: string, options?: DaemonOptions) => Promise<void>;
package/dist/daemon.js ADDED
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.startDaemon = void 0;
7
+ const node_net_1 = __importDefault(require("node:net"));
8
+ const node_fs_1 = require("node:fs");
9
+ const agent_js_1 = require("./agent.js");
10
+ const ipc_path_js_1 = require("./ipc-path.js");
11
+ const GRACE_PERIOD_MS = 10_000;
12
+ const PID_CHECK_INTERVAL_MS = 30_000;
13
+ /**
14
+ * Try connecting to a Unix socket / named pipe. Returns true if
15
+ * something is actively listening, false if the socket is stale or absent.
16
+ */
17
+ const isSocketLive = async (ipcPath) => new Promise((resolve) => {
18
+ const socket = node_net_1.default.createConnection(ipcPath, () => {
19
+ socket.destroy();
20
+ resolve(true);
21
+ });
22
+ socket.on('error', () => {
23
+ socket.destroy();
24
+ resolve(false);
25
+ });
26
+ });
27
+ /**
28
+ * Start the daemon process. Runs the agent and listens on an IPC socket
29
+ * for session connections from stub servers. Shuts down when no sessions
30
+ * remain after a grace period.
31
+ *
32
+ * This is invoked internally and should not be called directly by users.
33
+ */
34
+ const startDaemon = async (config, configIdentifier, options) => {
35
+ const ipcPath = (0, ipc_path_js_1.getIpcPath)(configIdentifier);
36
+ const pidPath = (0, ipc_path_js_1.getPidPath)(configIdentifier);
37
+ let connectionCount = 0;
38
+ let shutdownTimer;
39
+ const state = {};
40
+ const gracePeriodMs = options?.gracePeriodMs
41
+ ?? (process.env.DAEMON_GRACE_PERIOD_MS ? Number(process.env.DAEMON_GRACE_PERIOD_MS) : GRACE_PERIOD_MS);
42
+ const pidCheckIntervalMs = options?.pidCheckIntervalMs
43
+ ?? (process.env.DAEMON_PID_CHECK_INTERVAL_MS ? Number(process.env.DAEMON_PID_CHECK_INTERVAL_MS) : PID_CHECK_INTERVAL_MS);
44
+ const startAgentFn = options?.startAgent ?? agent_js_1.startAgent;
45
+ const cleanup = () => {
46
+ if (process.platform !== 'win32') {
47
+ try {
48
+ (0, node_fs_1.unlinkSync)(ipcPath);
49
+ }
50
+ catch {
51
+ // Already cleaned up
52
+ }
53
+ }
54
+ try {
55
+ (0, node_fs_1.unlinkSync)(pidPath);
56
+ }
57
+ catch {
58
+ // Already cleaned up
59
+ }
60
+ };
61
+ const shutdownNow = () => {
62
+ cleanup();
63
+ if (state.agentHandle) {
64
+ void state.agentHandle.stop().then(() => process.exit(0));
65
+ }
66
+ else {
67
+ process.exit(0);
68
+ }
69
+ };
70
+ const scheduleShutdown = () => {
71
+ if (shutdownTimer) {
72
+ clearTimeout(shutdownTimer);
73
+ }
74
+ shutdownTimer = setTimeout(() => {
75
+ if (connectionCount === 0) {
76
+ console.log('No sessions remaining. Shutting down daemon.');
77
+ shutdownNow();
78
+ }
79
+ }, gracePeriodMs);
80
+ };
81
+ // Before touching the socket, check if another daemon is already listening.
82
+ // Only clean up the socket file if it's stale (nothing listening).
83
+ if (process.platform !== 'win32') {
84
+ const live = await isSocketLive(ipcPath);
85
+ if (live) {
86
+ console.log('Another daemon is already listening. Exiting.');
87
+ process.exit(0);
88
+ }
89
+ // Socket file is stale or absent — safe to clean up
90
+ try {
91
+ (0, node_fs_1.unlinkSync)(ipcPath);
92
+ }
93
+ catch {
94
+ // Doesn't exist, fine
95
+ }
96
+ }
97
+ const server = node_net_1.default.createServer((socket) => {
98
+ connectionCount += 1;
99
+ console.log(`Session connected (${connectionCount} active)`);
100
+ if (shutdownTimer) {
101
+ clearTimeout(shutdownTimer);
102
+ shutdownTimer = undefined;
103
+ }
104
+ socket.on('close', () => {
105
+ connectionCount -= 1;
106
+ console.log(`Session disconnected (${connectionCount} active)`);
107
+ if (connectionCount === 0) {
108
+ scheduleShutdown();
109
+ }
110
+ });
111
+ socket.on('error', () => {
112
+ // Connection errors are handled by 'close'
113
+ });
114
+ });
115
+ // Wait for listen to succeed before writing PID file.
116
+ // This avoids overwriting another daemon's PID file if we lose the race.
117
+ await new Promise((resolve, reject) => {
118
+ server.listen(ipcPath, () => {
119
+ resolve();
120
+ });
121
+ server.on('error', (err) => {
122
+ if (err.code === 'EADDRINUSE') {
123
+ console.log('Another daemon is already listening. Exiting.');
124
+ process.exit(0);
125
+ }
126
+ reject(err);
127
+ });
128
+ });
129
+ console.log(`Daemon IPC listening on ${ipcPath}`);
130
+ // Now that we own the socket, write PID file
131
+ (0, node_fs_1.writeFileSync)(pidPath, String(process.pid), { mode: 0o600 });
132
+ // Periodically verify our PID file still points to us.
133
+ // If someone deleted it or another daemon overwrote it, we're orphaned.
134
+ const pidCheckInterval = setInterval(() => {
135
+ try {
136
+ if (!(0, node_fs_1.existsSync)(pidPath)) {
137
+ console.log('PID file deleted. Shutting down orphaned daemon.');
138
+ clearInterval(pidCheckInterval);
139
+ shutdownNow();
140
+ return;
141
+ }
142
+ const filePid = Number.parseInt((0, node_fs_1.readFileSync)(pidPath, 'utf-8').trim(), 10);
143
+ if (filePid !== process.pid) {
144
+ console.log(`PID file points to ${filePid}, not us (${process.pid}). Shutting down orphaned daemon.`);
145
+ clearInterval(pidCheckInterval);
146
+ // Don't clean up — the PID file belongs to the other daemon
147
+ if (state.agentHandle) {
148
+ void state.agentHandle.stop().then(() => process.exit(0));
149
+ }
150
+ else {
151
+ process.exit(0);
152
+ }
153
+ }
154
+ }
155
+ catch {
156
+ // If we can't read the PID file, assume we're still valid
157
+ }
158
+ }, pidCheckIntervalMs);
159
+ pidCheckInterval.unref();
160
+ // Start the actual agent (wrapped in try/catch to clean up on failure)
161
+ try {
162
+ state.agentHandle = await startAgentFn(config);
163
+ }
164
+ catch (err) {
165
+ console.error('Failed to start agent:', err instanceof Error ? err.message : err);
166
+ cleanup();
167
+ process.exit(1);
168
+ }
169
+ // Schedule shutdown in case no session connects
170
+ scheduleShutdown();
171
+ process.on('SIGINT', shutdownNow);
172
+ process.on('SIGTERM', shutdownNow);
173
+ };
174
+ exports.startDaemon = startDaemon;
package/dist/index.js CHANGED
@@ -3,14 +3,20 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const config_js_1 = require("./config.js");
5
5
  const relay_js_1 = require("./relay.js");
6
- const agent_js_1 = require("./agent.js");
6
+ const daemon_js_1 = require("./daemon.js");
7
+ const stub_server_js_1 = require("./stub-server.js");
8
+ const ipc_path_js_1 = require("./ipc-path.js");
7
9
  const main = async () => {
10
+ const isDaemonProcess = process.argv.includes('--internal-daemon-process-do-not-use-directly');
8
11
  const config = (0, config_js_1.loadConfig)();
9
12
  if (config.mode === 'relay') {
10
13
  (0, relay_js_1.startRelay)(config);
11
14
  }
15
+ else if (isDaemonProcess) {
16
+ await (0, daemon_js_1.startDaemon)(config, (0, ipc_path_js_1.resolveConfigIdentifier)());
17
+ }
12
18
  else {
13
- await (0, agent_js_1.startAgent)(config);
19
+ await (0, stub_server_js_1.startStubServer)(config, (0, ipc_path_js_1.resolveConfigIdentifier)());
14
20
  }
15
21
  };
16
22
  main().catch((err) => {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Determine a stable identifier for the current config source.
3
+ * This is used to derive unique IPC socket / PID file paths so that
4
+ * different relay configs get independent daemons.
5
+ */
6
+ export declare const resolveConfigIdentifier: () => string;
7
+ /**
8
+ * Get the IPC socket/pipe path for the daemon.
9
+ * Uses a hash of the config identifier to allow multiple independent daemons
10
+ * for different relay configurations.
11
+ *
12
+ * - macOS/Linux: Unix domain socket in ~/.config/mcp-local-tunnel/sockets/
13
+ * - Windows: Named pipe \\.\pipe\mcp-local-tunnel-<hash>
14
+ */
15
+ export declare const getIpcPath: (configIdentifier: string) => string;
16
+ /**
17
+ * Get the path for the daemon's PID file.
18
+ */
19
+ export declare const getPidPath: (configIdentifier: string) => string;
20
+ /**
21
+ * Get the path for daemon log output.
22
+ */
23
+ export declare const getLogPath: (configIdentifier: string) => string;
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getLogPath = exports.getPidPath = exports.getIpcPath = exports.resolveConfigIdentifier = void 0;
4
+ const node_path_1 = require("node:path");
5
+ const node_os_1 = require("node:os");
6
+ const node_crypto_1 = require("node:crypto");
7
+ const node_fs_1 = require("node:fs");
8
+ /**
9
+ * Determine a stable identifier for the current config source.
10
+ * This is used to derive unique IPC socket / PID file paths so that
11
+ * different relay configs get independent daemons.
12
+ */
13
+ const resolveConfigIdentifier = () => {
14
+ // If MCP_LOCAL_TUNNEL_CONFIG is set and points to a file, use the file path.
15
+ // If it's inline JSON, hash the content.
16
+ // Otherwise use the default config file path.
17
+ const raw = process.env.MCP_LOCAL_TUNNEL_CONFIG;
18
+ if (raw && !raw.startsWith('{') && (0, node_fs_1.existsSync)(raw)) {
19
+ return raw;
20
+ }
21
+ if (raw) {
22
+ return raw;
23
+ }
24
+ return 'mcp-local-tunnel.config.json';
25
+ };
26
+ exports.resolveConfigIdentifier = resolveConfigIdentifier;
27
+ /**
28
+ * Get the IPC socket/pipe path for the daemon.
29
+ * Uses a hash of the config identifier to allow multiple independent daemons
30
+ * for different relay configurations.
31
+ *
32
+ * - macOS/Linux: Unix domain socket in ~/.config/mcp-local-tunnel/sockets/
33
+ * - Windows: Named pipe \\.\pipe\mcp-local-tunnel-<hash>
34
+ */
35
+ const getIpcPath = (configIdentifier) => {
36
+ const hash = (0, node_crypto_1.createHash)('sha256').update(configIdentifier).digest('hex').slice(0, 12);
37
+ if (process.platform === 'win32') {
38
+ return `\\\\.\\pipe\\mcp-local-tunnel-${hash}`;
39
+ }
40
+ const dir = (0, node_path_1.join)(process.env.XDG_RUNTIME_DIR ?? (0, node_path_1.join)((0, node_os_1.homedir)(), '.config', 'mcp-local-tunnel'), 'sockets');
41
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
42
+ return (0, node_path_1.join)(dir, `daemon-${hash}.sock`);
43
+ };
44
+ exports.getIpcPath = getIpcPath;
45
+ /**
46
+ * Get the path for the daemon's PID file.
47
+ */
48
+ const getPidPath = (configIdentifier) => {
49
+ const hash = (0, node_crypto_1.createHash)('sha256').update(configIdentifier).digest('hex').slice(0, 12);
50
+ const dir = (0, node_path_1.join)((0, node_os_1.homedir)(), '.config', 'mcp-local-tunnel');
51
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
52
+ return (0, node_path_1.join)(dir, `daemon-${hash}.pid`);
53
+ };
54
+ exports.getPidPath = getPidPath;
55
+ /**
56
+ * Get the path for daemon log output.
57
+ */
58
+ const getLogPath = (configIdentifier) => {
59
+ const hash = (0, node_crypto_1.createHash)('sha256').update(configIdentifier).digest('hex').slice(0, 12);
60
+ const dir = (0, node_path_1.join)((0, node_os_1.homedir)(), '.config', 'mcp-local-tunnel', 'logs');
61
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
62
+ return (0, node_path_1.join)(dir, `daemon-${hash}.log`);
63
+ };
64
+ exports.getLogPath = getLogPath;
@@ -0,0 +1,8 @@
1
+ import type { AgentConfig } from './types.js';
2
+ /**
3
+ * Start a stub MCP server on stdio. This is what Claude Code / opencode
4
+ * sees as an MCP server. It manages the daemon lifecycle and responds to
5
+ * MCP requests with minimal/empty responses — actual tool usage goes
6
+ * through the tunnel relay, not through this stub.
7
+ */
8
+ export declare const startStubServer: (config: AgentConfig, configIdentifier: string) => Promise<void>;
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.startStubServer = void 0;
7
+ const node_net_1 = __importDefault(require("node:net"));
8
+ const node_child_process_1 = require("node:child_process");
9
+ const node_fs_1 = require("node:fs");
10
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
11
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
12
+ const ipc_path_js_1 = require("./ipc-path.js");
13
+ const agent_auth_js_1 = require("./agent-auth.js");
14
+ /**
15
+ * Try a single IPC connection attempt. Resolves with the socket on
16
+ * success, or undefined on failure. Always cleans up failed sockets.
17
+ */
18
+ const tryConnectOnce = async (ipcPath) => new Promise((resolve) => {
19
+ const socket = node_net_1.default.createConnection(ipcPath, () => {
20
+ resolve(socket);
21
+ });
22
+ socket.on('error', () => {
23
+ socket.destroy();
24
+ resolve(undefined);
25
+ });
26
+ });
27
+ /**
28
+ * Spawn the daemon as a detached background process.
29
+ */
30
+ const spawnDaemon = (configIdentifier) => {
31
+ const logPath = (0, ipc_path_js_1.getLogPath)(configIdentifier);
32
+ const logFd = (0, node_fs_1.openSync)(logPath, 'a');
33
+ const args = process.argv.slice(1).filter((a) => a !== '--internal-daemon-process-do-not-use-directly');
34
+ const child = (0, node_child_process_1.spawn)(process.execPath, [...args, '--internal-daemon-process-do-not-use-directly'], {
35
+ detached: true,
36
+ stdio: ['ignore', logFd, logFd],
37
+ env: process.env,
38
+ });
39
+ child.unref();
40
+ console.error(`Daemon spawned (pid ${child.pid}), logs: ${logPath}`);
41
+ };
42
+ const CONNECT_TIMEOUT_MS = 30_000;
43
+ const CONNECT_RETRY_MS = 200;
44
+ /**
45
+ * Connect to the daemon, spawning it if necessary. Strategy:
46
+ *
47
+ * 1. Try to connect to existing daemon
48
+ * 2. If that fails, ensure auth tokens are cached, spawn daemon
49
+ * 3. Retry connecting until success or timeout
50
+ *
51
+ * This approach is race-safe: if two stubs both spawn a daemon
52
+ * simultaneously, one daemon wins the IPC socket and the other exits.
53
+ * Both stubs eventually connect to the winning daemon.
54
+ */
55
+ const ensureDaemonAndConnect = async (config, configIdentifier) => {
56
+ const ipcPath = (0, ipc_path_js_1.getIpcPath)(configIdentifier);
57
+ // Fast path: daemon is already running
58
+ const existing = await tryConnectOnce(ipcPath);
59
+ if (existing) {
60
+ return existing;
61
+ }
62
+ // Daemon not running — pre-auth (we have a TTY, daemon doesn't)
63
+ // then spawn
64
+ await (0, agent_auth_js_1.getAccessToken)(config.relay);
65
+ spawnDaemon(configIdentifier);
66
+ // Poll until the daemon's IPC socket is ready
67
+ const deadline = Date.now() + CONNECT_TIMEOUT_MS;
68
+ while (Date.now() < deadline) {
69
+ // eslint-disable-next-line no-await-in-loop
70
+ const socket = await tryConnectOnce(ipcPath);
71
+ if (socket) {
72
+ return socket;
73
+ }
74
+ // eslint-disable-next-line no-await-in-loop
75
+ await new Promise((r) => {
76
+ setTimeout(r, CONNECT_RETRY_MS);
77
+ });
78
+ }
79
+ throw new Error(`Timed out waiting for daemon (${CONNECT_TIMEOUT_MS / 1000}s). Check logs: ${(0, ipc_path_js_1.getLogPath)(configIdentifier)}`);
80
+ };
81
+ /**
82
+ * Start a stub MCP server on stdio. This is what Claude Code / opencode
83
+ * sees as an MCP server. It manages the daemon lifecycle and responds to
84
+ * MCP requests with minimal/empty responses — actual tool usage goes
85
+ * through the tunnel relay, not through this stub.
86
+ */
87
+ const startStubServer = async (config, configIdentifier) => {
88
+ const daemonSocket = await ensureDaemonAndConnect(config, configIdentifier);
89
+ // Run a stub MCP server on stdio. Register then remove a dummy tool
90
+ // so the SDK installs the tools/list handler (returns empty list).
91
+ const server = new mcp_js_1.McpServer({ name: 'mcp-local-tunnel', version: '1.0.0' });
92
+ const registered = server.registerTool('_init', {}, () => ({ content: [] }));
93
+ registered.remove();
94
+ const transport = new stdio_js_1.StdioServerTransport();
95
+ await server.connect(transport);
96
+ // When stdin closes (session ends), disconnect from daemon
97
+ process.stdin.on('end', () => {
98
+ daemonSocket.destroy();
99
+ });
100
+ // If daemon disconnects, exit
101
+ daemonSocket.on('close', () => {
102
+ process.exit(0);
103
+ });
104
+ daemonSocket.on('error', () => {
105
+ process.exit(1);
106
+ });
107
+ };
108
+ exports.startStubServer = startStubServer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-local-tunnel",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Expose local MCP servers to remote clients without opening ports.",
5
5
  "license": "MIT",
6
6
  "author": "Adam Jones (domdomegg)",