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 +2 -0
- package/dist/agent.d.ts +5 -1
- package/dist/agent.js +42 -7
- package/dist/connection-manager.d.ts +2 -0
- package/dist/connection-manager.js +23 -1
- package/dist/daemon.d.ts +18 -0
- package/dist/daemon.js +174 -0
- package/dist/index.js +8 -2
- package/dist/ipc-path.d.ts +23 -0
- package/dist/ipc-path.js +64 -0
- package/dist/stub-server.d.ts +8 -0
- package/dist/stub-server.js +108 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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:
|
|
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
|
});
|
package/dist/daemon.d.ts
ADDED
|
@@ -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
|
|
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,
|
|
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;
|
package/dist/ipc-path.js
ADDED
|
@@ -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;
|