polydev-ai 1.9.36 → 1.9.37
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/lib/tunnelClient.js +231 -0
- package/mcp/stdio-wrapper.js +28 -2
- package/package.json +1 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TunnelClient — CLI-as-API client for the local MCP process.
|
|
3
|
+
*
|
|
4
|
+
* Polls the Polydev server for queued API requests, routes them to
|
|
5
|
+
* local CLI tools via cliManager, and posts responses back.
|
|
6
|
+
*
|
|
7
|
+
* Lifecycle:
|
|
8
|
+
* 1. start() → begins heartbeat (30s) + polling (3s)
|
|
9
|
+
* 2. pollForRequests() → GET /api/tunnel/pending
|
|
10
|
+
* 3. handleRequest() → cliManager.sendCliPrompt() → POST /api/tunnel/respond
|
|
11
|
+
* 4. stop() → clears intervals
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
class TunnelClient {
|
|
15
|
+
constructor(serverBaseUrl, authToken, cliManager) {
|
|
16
|
+
this.serverBaseUrl = serverBaseUrl.replace(/\/api\/mcp$/, ''); // strip /api/mcp if present
|
|
17
|
+
this.authToken = authToken;
|
|
18
|
+
this.cliManager = cliManager;
|
|
19
|
+
|
|
20
|
+
this.heartbeatInterval = null;
|
|
21
|
+
this.pollInterval = null;
|
|
22
|
+
this._processing = new Set(); // track in-flight request IDs
|
|
23
|
+
this._started = false;
|
|
24
|
+
|
|
25
|
+
// Configurable intervals
|
|
26
|
+
this.HEARTBEAT_INTERVAL_MS = 30_000; // 30s
|
|
27
|
+
this.POLL_INTERVAL_MS = 3_000; // 3s
|
|
28
|
+
this.CLI_TIMEOUT_MS = 120_000; // 2 min per request
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Start the tunnel client (heartbeat + polling)
|
|
33
|
+
*/
|
|
34
|
+
async start() {
|
|
35
|
+
if (this._started) return;
|
|
36
|
+
this._started = true;
|
|
37
|
+
|
|
38
|
+
console.error('[Tunnel] Starting CLI-as-API tunnel client');
|
|
39
|
+
|
|
40
|
+
// Send initial heartbeat immediately
|
|
41
|
+
try {
|
|
42
|
+
await this.sendHeartbeat();
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error('[Tunnel] Initial heartbeat failed:', err.message);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Start periodic heartbeat
|
|
48
|
+
this.heartbeatInterval = setInterval(() => {
|
|
49
|
+
this.sendHeartbeat().catch(err => {
|
|
50
|
+
console.error('[Tunnel] Heartbeat error:', err.message);
|
|
51
|
+
});
|
|
52
|
+
}, this.HEARTBEAT_INTERVAL_MS);
|
|
53
|
+
|
|
54
|
+
// Start polling for requests
|
|
55
|
+
this.pollInterval = setInterval(() => {
|
|
56
|
+
this.pollForRequests().catch(err => {
|
|
57
|
+
console.error('[Tunnel] Poll error:', err.message);
|
|
58
|
+
});
|
|
59
|
+
}, this.POLL_INTERVAL_MS);
|
|
60
|
+
|
|
61
|
+
console.error('[Tunnel] Tunnel client started (heartbeat: 30s, poll: 3s)');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Stop the tunnel client
|
|
66
|
+
*/
|
|
67
|
+
stop() {
|
|
68
|
+
if (this.heartbeatInterval) {
|
|
69
|
+
clearInterval(this.heartbeatInterval);
|
|
70
|
+
this.heartbeatInterval = null;
|
|
71
|
+
}
|
|
72
|
+
if (this.pollInterval) {
|
|
73
|
+
clearInterval(this.pollInterval);
|
|
74
|
+
this.pollInterval = null;
|
|
75
|
+
}
|
|
76
|
+
this._started = false;
|
|
77
|
+
console.error('[Tunnel] Tunnel client stopped');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Send heartbeat with available CLI providers
|
|
82
|
+
*/
|
|
83
|
+
async sendHeartbeat() {
|
|
84
|
+
const status = await this.cliManager.getCliStatus();
|
|
85
|
+
const providers = Object.entries(status)
|
|
86
|
+
.filter(([_, s]) => s.available && s.authenticated)
|
|
87
|
+
.map(([id]) => id);
|
|
88
|
+
|
|
89
|
+
const packageVersion = this._getPackageVersion();
|
|
90
|
+
|
|
91
|
+
const url = `${this.serverBaseUrl}/api/tunnel/heartbeat`;
|
|
92
|
+
const res = await fetch(url, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
'Authorization': `Bearer ${this.authToken}`,
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
available_providers: providers,
|
|
100
|
+
client_version: packageVersion,
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
const text = await res.text().catch(() => '');
|
|
106
|
+
throw new Error(`Heartbeat failed (${res.status}): ${text}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Poll for pending tunnel requests
|
|
112
|
+
*/
|
|
113
|
+
async pollForRequests() {
|
|
114
|
+
const url = `${this.serverBaseUrl}/api/tunnel/pending`;
|
|
115
|
+
const res = await fetch(url, {
|
|
116
|
+
method: 'GET',
|
|
117
|
+
headers: {
|
|
118
|
+
'Authorization': `Bearer ${this.authToken}`,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
// 401 is expected if token expired — don't spam logs
|
|
124
|
+
if (res.status === 401) return;
|
|
125
|
+
const text = await res.text().catch(() => '');
|
|
126
|
+
throw new Error(`Poll failed (${res.status}): ${text}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const data = await res.json();
|
|
130
|
+
const requests = data.requests || [];
|
|
131
|
+
|
|
132
|
+
for (const req of requests) {
|
|
133
|
+
// Skip if already processing
|
|
134
|
+
if (this._processing.has(req.id)) continue;
|
|
135
|
+
this._processing.add(req.id);
|
|
136
|
+
|
|
137
|
+
// Handle concurrently (don't await)
|
|
138
|
+
this.handleRequest(req).catch(err => {
|
|
139
|
+
console.error(`[Tunnel] Request ${req.id} error:`, err.message);
|
|
140
|
+
}).finally(() => {
|
|
141
|
+
this._processing.delete(req.id);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Handle a single tunnel request by routing to CLI
|
|
148
|
+
*/
|
|
149
|
+
async handleRequest(request) {
|
|
150
|
+
const startTime = Date.now();
|
|
151
|
+
console.error(`[Tunnel] Processing request ${request.id} → ${request.provider}`);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const result = await this.cliManager.sendCliPrompt(
|
|
155
|
+
request.provider,
|
|
156
|
+
request.prompt,
|
|
157
|
+
'args',
|
|
158
|
+
this.CLI_TIMEOUT_MS,
|
|
159
|
+
request.model_requested || null
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const latencyMs = Date.now() - startTime;
|
|
163
|
+
|
|
164
|
+
if (result.success) {
|
|
165
|
+
console.error(`[Tunnel] Request ${request.id} completed (${latencyMs}ms)`);
|
|
166
|
+
await this.sendResponse({
|
|
167
|
+
request_id: request.id,
|
|
168
|
+
content: result.content || '',
|
|
169
|
+
model_used: result.model || result.detectedModel || request.provider,
|
|
170
|
+
tokens_used: result.tokens_used || null,
|
|
171
|
+
latency_ms: latencyMs,
|
|
172
|
+
});
|
|
173
|
+
} else {
|
|
174
|
+
console.error(`[Tunnel] Request ${request.id} CLI error: ${result.error}`);
|
|
175
|
+
await this.sendResponse({
|
|
176
|
+
request_id: request.id,
|
|
177
|
+
error: result.error || 'CLI execution failed',
|
|
178
|
+
latency_ms: latencyMs,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const latencyMs = Date.now() - startTime;
|
|
183
|
+
console.error(`[Tunnel] Request ${request.id} exception: ${err.message}`);
|
|
184
|
+
await this.sendResponse({
|
|
185
|
+
request_id: request.id,
|
|
186
|
+
error: err.message || 'Unexpected error',
|
|
187
|
+
latency_ms: latencyMs,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Send response back to server
|
|
194
|
+
*/
|
|
195
|
+
async sendResponse(responseData) {
|
|
196
|
+
const url = `${this.serverBaseUrl}/api/tunnel/respond`;
|
|
197
|
+
const res = await fetch(url, {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: {
|
|
200
|
+
'Authorization': `Bearer ${this.authToken}`,
|
|
201
|
+
'Content-Type': 'application/json',
|
|
202
|
+
},
|
|
203
|
+
body: JSON.stringify(responseData),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
if (!res.ok) {
|
|
207
|
+
const text = await res.text().catch(() => '');
|
|
208
|
+
console.error(`[Tunnel] Failed to submit response for ${responseData.request_id}: ${res.status} ${text}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get package version from package.json
|
|
214
|
+
*/
|
|
215
|
+
_getPackageVersion() {
|
|
216
|
+
try {
|
|
217
|
+
const path = require('path');
|
|
218
|
+
const fs = require('fs');
|
|
219
|
+
const pkgPath = path.join(__dirname, '..', 'package.json');
|
|
220
|
+
if (fs.existsSync(pkgPath)) {
|
|
221
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
222
|
+
return pkg.version || 'unknown';
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// ignore
|
|
226
|
+
}
|
|
227
|
+
return 'unknown';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = { TunnelClient };
|
package/mcp/stdio-wrapper.js
CHANGED
|
@@ -49,6 +49,7 @@ const fs = require('fs');
|
|
|
49
49
|
const path = require('path');
|
|
50
50
|
const os = require('os');
|
|
51
51
|
const { CLIManager } = require('../lib/cliManager');
|
|
52
|
+
const { TunnelClient } = require('../lib/tunnelClient');
|
|
52
53
|
|
|
53
54
|
// MCP stdio servers must only emit JSON-RPC on stdout.
|
|
54
55
|
// Redirect any accidental console output to stderr to avoid handshake failures.
|
|
@@ -188,8 +189,7 @@ function cleanCliResponse(content) {
|
|
|
188
189
|
// State: metadata - skip until we hit a section marker
|
|
189
190
|
if (state === 'metadata') {
|
|
190
191
|
// Skip known metadata patterns
|
|
191
|
-
if (!trimmed) continue;
|
|
192
|
-
if (trimmed.startsWith('provider:')) continue;
|
|
192
|
+
if (!trimmed || trimmed.startsWith('provider:')) continue;
|
|
193
193
|
if (trimmed.startsWith('approval:')) continue;
|
|
194
194
|
if (trimmed.startsWith('sandbox:')) continue;
|
|
195
195
|
if (trimmed.startsWith('reasoning effort:')) continue;
|
|
@@ -355,6 +355,9 @@ class StdioMCPWrapper {
|
|
|
355
355
|
|
|
356
356
|
// Login server reference (for cleanup)
|
|
357
357
|
this._loginServer = null;
|
|
358
|
+
|
|
359
|
+
// CLI-as-API tunnel client (started after CLI detection)
|
|
360
|
+
this.tunnelClient = null;
|
|
358
361
|
}
|
|
359
362
|
|
|
360
363
|
loadManifest() {
|
|
@@ -2950,6 +2953,27 @@ To re-login: /polydev:login`
|
|
|
2950
2953
|
}
|
|
2951
2954
|
}
|
|
2952
2955
|
|
|
2956
|
+
/**
|
|
2957
|
+
* Start CLI-as-API tunnel client if authenticated.
|
|
2958
|
+
* Runs in background — polls for external API requests and routes them to local CLIs.
|
|
2959
|
+
*/
|
|
2960
|
+
startTunnelClient() {
|
|
2961
|
+
if (!this.userToken) {
|
|
2962
|
+
return; // No auth, skip tunnel
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
try {
|
|
2966
|
+
this.tunnelClient = new TunnelClient(
|
|
2967
|
+
this.serverUrl, // https://www.polydev.ai/api/mcp
|
|
2968
|
+
this.userToken,
|
|
2969
|
+
this.cliManager
|
|
2970
|
+
);
|
|
2971
|
+
this.tunnelClient.start();
|
|
2972
|
+
} catch (err) {
|
|
2973
|
+
console.error('[Tunnel] Failed to start tunnel client:', err.message);
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2953
2977
|
/**
|
|
2954
2978
|
* Record local usage for analytics
|
|
2955
2979
|
*/
|
|
@@ -3223,6 +3247,8 @@ To re-login: /polydev:login`
|
|
|
3223
3247
|
this._cliDetectionResolver();
|
|
3224
3248
|
}
|
|
3225
3249
|
this.startSmartRefreshScheduler();
|
|
3250
|
+
// Start CLI-as-API tunnel client (background, non-blocking)
|
|
3251
|
+
this.startTunnelClient();
|
|
3226
3252
|
});
|
|
3227
3253
|
} else {
|
|
3228
3254
|
// No token - CLI detection will run after login completes
|