@hcu-lab.me/mcp-device 1.0.4 → 1.0.6

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,343 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const fsp = fs.promises;
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const net = require('net');
8
+ const { execFile } = require('child_process');
9
+
10
+ const statePath = process.argv[2];
11
+ const HANDOFF_WAIT_MS = 120000;
12
+ const PID_WAIT_MS = 30000;
13
+ const PACKAGE_UNLOCK_WAIT_MS = 20000;
14
+
15
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
16
+
17
+ function run(file, args) {
18
+ return new Promise((resolve, reject) => {
19
+ execFile(file, args, { windowsHide: true, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
20
+ if (error) {
21
+ error.stdout = stdout;
22
+ error.stderr = stderr;
23
+ reject(error);
24
+ return;
25
+ }
26
+ resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
27
+ });
28
+ });
29
+ }
30
+
31
+ async function exists(filePath) {
32
+ try { await fsp.access(filePath); return true; } catch { return false; }
33
+ }
34
+
35
+ async function readState() {
36
+ return JSON.parse(await fsp.readFile(statePath, 'utf8'));
37
+ }
38
+
39
+ async function writeState(patch) {
40
+ const current = await readState();
41
+ const next = Object.assign({}, current, patch, { updatedAt: Date.now() });
42
+ const tmp =
43
+ statePath + '.' + process.pid + '.' + Date.now() + '.' +
44
+ crypto.randomBytes(6).toString('hex') + '.tmp';
45
+ await fsp.writeFile(tmp, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
46
+ await fsp.rename(tmp, statePath);
47
+ try { await fsp.chmod(statePath, 0o600); } catch {}
48
+ return next;
49
+ }
50
+
51
+ function isAlive(pid) {
52
+ if (!pid || pid < 1) return false;
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ async function waitForFile(filePath, timeoutMs) {
62
+ const deadline = Date.now() + timeoutMs;
63
+ while (Date.now() < deadline) {
64
+ if (await exists(filePath)) return true;
65
+ await sleep(100);
66
+ }
67
+ return await exists(filePath);
68
+ }
69
+
70
+ async function waitForPids(pids, timeoutMs) {
71
+ const filtered = pids.filter(pid => Number.isInteger(pid) && pid > 0);
72
+ const deadline = Date.now() + timeoutMs;
73
+ while (Date.now() < deadline) {
74
+ if (filtered.every(pid => !isAlive(pid))) return true;
75
+ await sleep(100);
76
+ }
77
+ return filtered.every(pid => !isAlive(pid));
78
+ }
79
+
80
+ async function stopManager(state) {
81
+ if (state.managerKind === 'windows-task') {
82
+ // The update helper runs in a separate task, so ending the original device
83
+ // task cannot terminate the helper itself. "not running" is harmless.
84
+ await run(state.schtasksPath, ['/End', '/TN', state.windowsTaskName]).catch(() => {});
85
+ } else if (state.managerKind === 'systemd') {
86
+ await run(state.systemctlPath, ['--user', 'stop', state.systemdUnit]);
87
+ } else if (state.managerKind === 'pm2') {
88
+ await run(state.pm2Path, ['stop', state.pm2Name]);
89
+ }
90
+ }
91
+
92
+ async function startWindowsRunner(state) {
93
+ if (state.managerKind === 'windows-task') {
94
+ await run(state.schtasksPath, ['/Run', '/TN', state.windowsTaskName]);
95
+ return;
96
+ }
97
+
98
+ const escapePs = value => "'" + String(value).replace(/'/g, "''") + "'";
99
+ const command =
100
+ 'Start-Process -FilePath ' + escapePs(state.powershellPath) +
101
+ " -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-WindowStyle','Hidden','-File'," +
102
+ escapePs(state.windowsRunnerPath) + ') -WindowStyle Hidden';
103
+ await run(state.powershellPath, ['-NoProfile', '-NonInteractive', '-Command', command]);
104
+ }
105
+
106
+ async function startManager(state) {
107
+ if (String(state.managerKind).startsWith('windows-')) {
108
+ await startWindowsRunner(state);
109
+ return;
110
+ }
111
+ if (state.managerKind === 'systemd') {
112
+ await run(state.systemctlPath, ['--user', 'start', state.systemdUnit]);
113
+ return;
114
+ }
115
+ if (state.managerKind === 'pm2') {
116
+ await run(state.pm2Path, ['restart', state.pm2Name]);
117
+ await run(state.pm2Path, ['save']);
118
+ return;
119
+ }
120
+ throw new Error('Unsupported manager kind: ' + state.managerKind);
121
+ }
122
+
123
+ async function renameWithRetry(source, target, timeoutMs) {
124
+ const deadline = Date.now() + timeoutMs;
125
+ let lastError = null;
126
+ while (Date.now() < deadline) {
127
+ try {
128
+ await fsp.rename(source, target);
129
+ return;
130
+ } catch (error) {
131
+ lastError = error;
132
+ const code = String(error && error.code || '');
133
+ if (!['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY'].includes(code)) throw error;
134
+ await sleep(150);
135
+ }
136
+ }
137
+ throw lastError || new Error('Timed out waiting for package directory to unlock.');
138
+ }
139
+
140
+ async function packageVersion(packageRoot) {
141
+ try {
142
+ const manifest = JSON.parse(await fsp.readFile(path.join(packageRoot, 'package.json'), 'utf8'));
143
+ return String(manifest.version || '');
144
+ } catch {
145
+ return '';
146
+ }
147
+ }
148
+
149
+ async function assertRuntimeOwnerReleased(state) {
150
+ const controlPath = String(state.runtimeOwnerControlPath || '');
151
+ if (!controlPath) {
152
+ const failure = new Error('Runtime owner control path is missing from update state.');
153
+ failure.code = 'DEVICE_UPDATE_RUNTIME_OWNER_UNVERIFIED';
154
+ throw failure;
155
+ }
156
+ let control;
157
+ try {
158
+ control = JSON.parse(await fsp.readFile(controlPath, 'utf8'));
159
+ } catch (error) {
160
+ if (error && error.code === 'ENOENT') return;
161
+ const failure = new Error('Unable to verify MCP Device runtime ownership release.');
162
+ failure.code = 'DEVICE_UPDATE_RUNTIME_OWNER_UNVERIFIED';
163
+ throw failure;
164
+ }
165
+
166
+ const endpoint = String(control && control.endpoint || '');
167
+ if (!endpoint) {
168
+ const failure = new Error('Runtime owner control state has no endpoint; refusing package replacement.');
169
+ failure.code = 'DEVICE_UPDATE_RUNTIME_OWNER_UNVERIFIED';
170
+ throw failure;
171
+ }
172
+
173
+ const active = await new Promise(resolve => {
174
+ const socket = net.createConnection(endpoint);
175
+ let data = '';
176
+ let settled = false;
177
+ let connected = false;
178
+ const finish = value => {
179
+ if (settled) return;
180
+ settled = true;
181
+ socket.destroy();
182
+ resolve(value);
183
+ };
184
+ socket.setEncoding('utf8');
185
+ socket.setTimeout(800, () => finish(connected));
186
+ socket.once('error', () => finish(false));
187
+ socket.on('data', chunk => { data += chunk; });
188
+ socket.once('end', () => {
189
+ try {
190
+ const parsed = JSON.parse(data);
191
+ finish(parsed && parsed.ok === true && parsed.status && parsed.status.product === 'MCP Device');
192
+ } catch {
193
+ // A live endpoint with an unreadable response is not safe to replace.
194
+ finish(true);
195
+ }
196
+ });
197
+ socket.once('connect', () => {
198
+ connected = true;
199
+ socket.write(JSON.stringify({ action: 'status' }) + '\n');
200
+ });
201
+ });
202
+
203
+ if (active) {
204
+ const failure = new Error('A live MCP Device runtime owner still exists; refusing package replacement.');
205
+ failure.code = 'DEVICE_UPDATE_RUNTIME_OWNER_ACTIVE';
206
+ throw failure;
207
+ }
208
+
209
+ // A stale control file is harmless. Do not delete it here: a new owner could
210
+ // have acquired the endpoint between checks, and RuntimeOwner owns cleanup.
211
+ }
212
+
213
+ async function restoreRollback(state) {
214
+ if (await exists(state.rollbackPath)) {
215
+ await fsp.rm(state.packageRoot, { recursive: true, force: true }).catch(() => {});
216
+ await fsp.rename(state.rollbackPath, state.packageRoot);
217
+ } else {
218
+ const current = await packageVersion(state.packageRoot);
219
+ if (current === state.fromVersion) return;
220
+ if (!await exists(state.rollbackSnapshot)) {
221
+ throw new Error('Offline rollback package is unavailable.');
222
+ }
223
+ await fsp.rm(state.packageRoot, { recursive: true, force: true }).catch(() => {});
224
+ await fsp.cp(state.rollbackSnapshot, state.packageRoot, {
225
+ recursive: true,
226
+ force: true,
227
+ preserveTimestamps: true
228
+ });
229
+ }
230
+
231
+ const restored = await packageVersion(state.packageRoot);
232
+ if (restored !== state.fromVersion) {
233
+ throw new Error('Rollback package verification failed: expected ' + state.fromVersion + ', got ' + (restored || 'unknown') + '.');
234
+ }
235
+ }
236
+
237
+ async function cleanupWindowsUpdateTask(state) {
238
+ if (!state.windowsUpdateTaskName || !state.schtasksPath) return;
239
+ await run(state.schtasksPath, ['/Delete', '/F', '/TN', state.windowsUpdateTaskName]).catch(() => {});
240
+ }
241
+
242
+ async function main() {
243
+ let state = await readState();
244
+
245
+ try {
246
+ if (!await waitForFile(state.handoffPath, HANDOFF_WAIT_MS)) {
247
+ const error = new Error('Timed out waiting for MCP Device update handoff.');
248
+ error.code = 'DEVICE_UPDATE_HANDOFF_TIMEOUT';
249
+ throw error;
250
+ }
251
+
252
+ state = await writeState({ state: 'quiescing' });
253
+ await stopManager(state);
254
+
255
+ if (!await waitForPids([state.parentPid, state.childPid], PID_WAIT_MS)) {
256
+ const error = new Error('MCP Device parent/child did not exit before update.');
257
+ error.code = 'DEVICE_UPDATE_RUNTIME_STILL_ALIVE';
258
+ throw error;
259
+ }
260
+
261
+ await assertRuntimeOwnerReleased(state);
262
+
263
+ if (await exists(state.rollbackPath)) {
264
+ const error = new Error('Rollback path already exists; refusing to overwrite recovery evidence.');
265
+ error.code = 'DEVICE_UPDATE_ROLLBACK_PRESENT';
266
+ throw error;
267
+ }
268
+
269
+ await renameWithRetry(state.packageRoot, state.rollbackPath, PACKAGE_UNLOCK_WAIT_MS);
270
+ state = await writeState({ state: 'installing' });
271
+
272
+ await run(state.nodePath, [
273
+ state.npmCliPath,
274
+ 'install',
275
+ '-g',
276
+ state.targetTarball,
277
+ '--offline',
278
+ '--cache', state.cacheDir,
279
+ '--prefix', state.globalPrefix,
280
+ '--no-audit',
281
+ '--no-fund'
282
+ ]);
283
+
284
+ const actual = await packageVersion(state.packageRoot);
285
+ if (actual !== state.targetVersion) {
286
+ const error = new Error('Installed target verification failed: expected ' + state.targetVersion + ', got ' + (actual || 'unknown') + '.');
287
+ error.code = 'DEVICE_UPDATE_VERIFY_FAILED';
288
+ throw error;
289
+ }
290
+
291
+ state = await writeState({
292
+ state: 'installed_waiting_reconnect',
293
+ completedAt: Date.now(),
294
+ errorCode: null,
295
+ message: null
296
+ });
297
+
298
+ await startManager(state);
299
+ await cleanupWindowsUpdateTask(state);
300
+ process.exit(0);
301
+ } catch (error) {
302
+ const code = String(error && error.code || 'DEVICE_UPDATE_FAILED').slice(0, 64);
303
+ const message = String(error && error.message || error).slice(0, 240);
304
+
305
+ try {
306
+ state = await writeState({ state: 'rollback', errorCode: code, message });
307
+ await restoreRollback(state);
308
+ state = await writeState({
309
+ state: 'failed',
310
+ completedAt: Date.now(),
311
+ errorCode: code,
312
+ message
313
+ });
314
+
315
+ // Never create a second runtime while any tracked process or owner is
316
+ // still alive. If quiescence failed, preserve bounded failure evidence
317
+ // and leave the original runtime as the only authority.
318
+ const trackedRuntimeAlive = [state.parentPid, state.childPid]
319
+ .filter(pid => Number.isInteger(pid) && pid > 0)
320
+ .some(isAlive);
321
+ if (!trackedRuntimeAlive) {
322
+ try {
323
+ await assertRuntimeOwnerReleased(state);
324
+ await startManager(state);
325
+ } catch {
326
+ // Failure state remains durable for the next safe/manual recovery.
327
+ }
328
+ }
329
+ } catch (rollbackError) {
330
+ await writeState({
331
+ state: 'failed',
332
+ completedAt: Date.now(),
333
+ errorCode: 'DEVICE_UPDATE_ROLLBACK_FAILED',
334
+ message: String(rollbackError && rollbackError.message || rollbackError).slice(0, 240)
335
+ }).catch(() => {});
336
+ }
337
+
338
+ await cleanupWindowsUpdateTask(state).catch(() => {});
339
+ process.exit(1);
340
+ }
341
+ }
342
+
343
+ main().catch(() => process.exit(1));
@@ -53,6 +53,9 @@ export async function handleReadFile(args) {
53
53
  }
54
54
  const readFileOperation = async () => {
55
55
  const parsed = ReadFileArgsSchema.parse(args);
56
+ if (process.env.MCP_DEVICE_REMOTE === 'true' && parsed.isUrl) {
57
+ return createErrorResponse('REMOTE_URL_READ_DISABLED: URL reads are disabled for MCP Device remote execution.');
58
+ }
56
59
  // Get the configuration for file read limits
57
60
  const config = await configManager.getConfig();
58
61
  if (!config) {
package/dist/index.js CHANGED
@@ -38,9 +38,13 @@ async function runServer() {
38
38
  deferLog('info', 'Loading configuration...');
39
39
  await configManager.loadConfig();
40
40
  deferLog('info', 'Configuration loaded successfully');
41
- // Initialize feature flags (non-blocking)
42
- deferLog('info', 'Initializing feature flags...');
43
- await featureFlagManager.initialize();
41
+ // Remote MCP Device children must not depend on legacy host
42
+ // feature flags or cached A/B state. Their execution surface is fixed by
43
+ // GatewayToolAdapter and remote-mode behavior must be deterministic.
44
+ if (process.env.MCP_DEVICE_REMOTE !== 'true') {
45
+ deferLog('info', 'Initializing feature flags...');
46
+ await featureFlagManager.initialize();
47
+ }
44
48
  }
45
49
  catch (configError) {
46
50
  deferLog('error', `Failed to load configuration: ${configError instanceof Error ? configError.message : String(configError)}`);
@@ -1,3 +1,8 @@
1
+ import { GatewayDeviceIdentity } from '../device/gateway-identity.js';
1
2
  import { GatewayDeviceStatusStore } from '../device/device-status.js';
2
3
  export declare function applyServiceCommandStatus(store: GatewayDeviceStatusStore, command: 'install' | 'start' | 'stop' | 'uninstall'): Promise<void>;
4
+ export declare function resolvePairingIdentity(identity: GatewayDeviceIdentity, options?: {
5
+ forcePair?: boolean;
6
+ probeCurrentIdentity?: () => Promise<void>;
7
+ }): Promise<import("../device/gateway-identity.js").GatewayIdentityRecord>;
3
8
  export declare function runRemote(): Promise<void>;
@@ -111,13 +111,32 @@ async function withBootstrapOwner(action, allowTakeover = true) {
111
111
  await owner.release();
112
112
  }
113
113
  }
114
+ export async function resolvePairingIdentity(identity, options = {}) {
115
+ const current = await identity.loadOrCreate();
116
+ if (!options.forcePair || deviceIdentityNeedsPairing(current))
117
+ return current;
118
+ if (typeof options.probeCurrentIdentity !== 'function') {
119
+ throw new Error('Current device identity probe is required before forced pairing.');
120
+ }
121
+ try {
122
+ await options.probeCurrentIdentity();
123
+ return current;
124
+ }
125
+ catch (error) {
126
+ if (String(error?.code || '') !== 'DEVICE_FORGOTTEN')
127
+ throw error;
128
+ await identity.forget();
129
+ return await identity.loadOrCreate();
130
+ }
131
+ }
114
132
  async function ensureLinkedDeviceUnderOwner(forcePair = false) {
115
133
  const store = new GatewayDeviceStatusStore();
116
134
  const current = await store.load();
117
135
  const config = await captureGatewayConfigFromEnvironment(new GatewayDeviceConfigStore(), { fallbackGatewayUrl: current.gatewayUrl });
118
136
  const gatewayUrl = String(config.gatewayUrl || '').trim();
119
137
  const identity = new GatewayDeviceIdentity();
120
- const record = await identity.loadOrCreate();
138
+ let record = await identity.loadOrCreate();
139
+ const initialDeviceId = record.deviceId;
121
140
  await store.update({
122
141
  gatewayUrl,
123
142
  deviceId: record.deviceId,
@@ -130,6 +149,36 @@ async function ensureLinkedDeviceUnderOwner(forcePair = false) {
130
149
  const proxy = createGatewayProxyAgent(config);
131
150
  let channel;
132
151
  try {
152
+ record = await resolvePairingIdentity(identity, {
153
+ forcePair,
154
+ probeCurrentIdentity: async () => {
155
+ const probe = new GatewayDeviceChannel({
156
+ gatewayUrl,
157
+ identity,
158
+ proxyAgent: proxy.agent,
159
+ securityProtocolFloor: config.securityProtocolFloor,
160
+ appCaPem: config.appCaPem || undefined,
161
+ adapter: controlAdapter(),
162
+ agentVersion: process.env.npm_package_version
163
+ });
164
+ try {
165
+ await probe.start();
166
+ }
167
+ finally {
168
+ await probe.stop().catch(() => { });
169
+ }
170
+ }
171
+ });
172
+ if (record.deviceId !== initialDeviceId) {
173
+ await store.update({
174
+ gatewayUrl,
175
+ deviceId: record.deviceId,
176
+ deviceName: os.hostname(),
177
+ identityPresent: true,
178
+ account: { connected: false, label: null },
179
+ connection: { ...current.connection, online: false }
180
+ });
181
+ }
133
182
  const pairing = await pairGatewayDevice({ gatewayUrl, identity, proxyAgent: proxy.agent, deviceName: os.hostname() });
134
183
  await store.update({
135
184
  gatewayUrl,
package/dist/server.js CHANGED
@@ -147,7 +147,11 @@ server.setRequestHandler(InitializeRequestSchema, async (request) => {
147
147
  && currentClient.name !== 'desktop-commander'
148
148
  && !isRemoteClientContext(currentClient.name)
149
149
  && !global.disableOnboarding;
150
- if (isWelcomePageEligibleClient) {
150
+ if (isRemoteClientContext(currentClient.name)) {
151
+ // Remote MCP Device children must not mutate local onboarding
152
+ // state merely because they initialized an inherited MCP server.
153
+ }
154
+ else if (isWelcomePageEligibleClient) {
151
155
  await handleWelcomePageOnboarding(currentClient.name);
152
156
  }
153
157
  else {
@@ -1422,7 +1426,7 @@ async function handleCallToolRequest(request) {
1422
1426
  'get_recent_tool_calls',
1423
1427
  'track_ui_event'
1424
1428
  ];
1425
- if (!EXCLUDED_TOOLS.includes(name)) {
1429
+ if (!currentCallIsRemote && process.env.MCP_DEVICE_REMOTE !== 'true' && !EXCLUDED_TOOLS.includes(name)) {
1426
1430
  toolHistory.addCall(name, args, result, duration);
1427
1431
  }
1428
1432
  // Track success or failure based on result
@@ -1436,77 +1440,79 @@ async function handleCallToolRequest(request) {
1436
1440
  else {
1437
1441
  await usageTracker.trackSuccess(name);
1438
1442
  console.log(`[FEEDBACK DEBUG] Tool ${name} succeeded, checking feedback...`);
1439
- // Check if should show onboarding (before feedback - first-time users are priority)
1440
- const shouldShowOnboarding = await usageTracker.shouldShowOnboarding();
1441
- console.log(`[ONBOARDING DEBUG] Should show onboarding: ${shouldShowOnboarding}`);
1442
- if (shouldShowOnboarding) {
1443
- console.log(`[ONBOARDING DEBUG] Generating onboarding message...`);
1444
- const onboardingResult = await usageTracker.getOnboardingMessage();
1445
- console.log(`[ONBOARDING DEBUG] Generated variant: ${onboardingResult.variant}`);
1446
- // Capture onboarding prompt injection event
1447
- const stats = await usageTracker.getStats();
1448
- await capture('server_onboarding_shown', {
1449
- trigger_tool: name,
1450
- total_calls: stats.totalToolCalls,
1451
- successful_calls: stats.successfulCalls,
1452
- days_since_first_use: Math.floor((Date.now() - stats.firstUsed) / (1000 * 60 * 60 * 24)),
1453
- total_sessions: stats.totalSessions,
1454
- message_variant: onboardingResult.variant
1455
- });
1456
- // Inject onboarding message for the LLM
1457
- if (result.content && result.content.length > 0 && result.content[0].type === "text") {
1458
- const currentContent = result.content[0].text || '';
1459
- result.content[0].text = `${currentContent}${onboardingResult.message}`;
1460
- }
1461
- else {
1462
- result.content = [
1463
- ...(result.content || []),
1464
- {
1465
- type: "text",
1466
- text: onboardingResult.message
1467
- }
1468
- ];
1469
- }
1470
- // Mark that we've shown onboarding (to prevent spam)
1471
- await usageTracker.markOnboardingShown(onboardingResult.variant);
1472
- }
1473
- // Check if should prompt for feedback (only on successful operations)
1474
- const shouldPrompt = await usageTracker.shouldPromptForFeedback();
1475
- console.log(`[FEEDBACK DEBUG] Should prompt for feedback: ${shouldPrompt}`);
1476
- if (shouldPrompt) {
1477
- console.log(`[FEEDBACK DEBUG] Generating feedback message...`);
1478
- const feedbackResult = await usageTracker.getFeedbackPromptMessage();
1479
- console.log(`[FEEDBACK DEBUG] Generated variant: ${feedbackResult.variant}`);
1480
- // Capture feedback prompt injection event
1481
- const stats = await usageTracker.getStats();
1482
- await capture('feedback_prompt_injected', {
1483
- trigger_tool: name,
1484
- total_calls: stats.totalToolCalls,
1485
- successful_calls: stats.successfulCalls,
1486
- failed_calls: stats.failedCalls,
1487
- days_since_first_use: Math.floor((Date.now() - stats.firstUsed) / (1000 * 60 * 60 * 24)),
1488
- total_sessions: stats.totalSessions,
1489
- message_variant: feedbackResult.variant
1490
- });
1491
- // Inject feedback instruction for the LLM
1492
- if (result.content && result.content.length > 0 && result.content[0].type === "text") {
1493
- const currentContent = result.content[0].text || '';
1494
- result.content[0].text = `${currentContent}${feedbackResult.message}`;
1443
+ if (!currentCallIsRemote && !isRemoteClientContext(currentClient.name)) {
1444
+ // Check if should show onboarding (before feedback - first-time users are priority)
1445
+ const shouldShowOnboarding = await usageTracker.shouldShowOnboarding();
1446
+ console.log(`[ONBOARDING DEBUG] Should show onboarding: ${shouldShowOnboarding}`);
1447
+ if (shouldShowOnboarding) {
1448
+ console.log(`[ONBOARDING DEBUG] Generating onboarding message...`);
1449
+ const onboardingResult = await usageTracker.getOnboardingMessage();
1450
+ console.log(`[ONBOARDING DEBUG] Generated variant: ${onboardingResult.variant}`);
1451
+ // Capture onboarding prompt injection event
1452
+ const stats = await usageTracker.getStats();
1453
+ await capture('server_onboarding_shown', {
1454
+ trigger_tool: name,
1455
+ total_calls: stats.totalToolCalls,
1456
+ successful_calls: stats.successfulCalls,
1457
+ days_since_first_use: Math.floor((Date.now() - stats.firstUsed) / (1000 * 60 * 60 * 24)),
1458
+ total_sessions: stats.totalSessions,
1459
+ message_variant: onboardingResult.variant
1460
+ });
1461
+ // Inject onboarding message for the LLM
1462
+ if (result.content && result.content.length > 0 && result.content[0].type === "text") {
1463
+ const currentContent = result.content[0].text || '';
1464
+ result.content[0].text = `${currentContent}${onboardingResult.message}`;
1465
+ }
1466
+ else {
1467
+ result.content = [
1468
+ ...(result.content || []),
1469
+ {
1470
+ type: "text",
1471
+ text: onboardingResult.message
1472
+ }
1473
+ ];
1474
+ }
1475
+ // Mark that we've shown onboarding (to prevent spam)
1476
+ await usageTracker.markOnboardingShown(onboardingResult.variant);
1495
1477
  }
1496
- else {
1497
- result.content = [
1498
- ...(result.content || []),
1499
- {
1500
- type: "text",
1501
- text: feedbackResult.message
1502
- }
1503
- ];
1478
+ // Check if should prompt for feedback (only on successful operations)
1479
+ const shouldPrompt = await usageTracker.shouldPromptForFeedback();
1480
+ console.log(`[FEEDBACK DEBUG] Should prompt for feedback: ${shouldPrompt}`);
1481
+ if (shouldPrompt) {
1482
+ console.log(`[FEEDBACK DEBUG] Generating feedback message...`);
1483
+ const feedbackResult = await usageTracker.getFeedbackPromptMessage();
1484
+ console.log(`[FEEDBACK DEBUG] Generated variant: ${feedbackResult.variant}`);
1485
+ // Capture feedback prompt injection event
1486
+ const stats = await usageTracker.getStats();
1487
+ await capture('feedback_prompt_injected', {
1488
+ trigger_tool: name,
1489
+ total_calls: stats.totalToolCalls,
1490
+ successful_calls: stats.successfulCalls,
1491
+ failed_calls: stats.failedCalls,
1492
+ days_since_first_use: Math.floor((Date.now() - stats.firstUsed) / (1000 * 60 * 60 * 24)),
1493
+ total_sessions: stats.totalSessions,
1494
+ message_variant: feedbackResult.variant
1495
+ });
1496
+ // Inject feedback instruction for the LLM
1497
+ if (result.content && result.content.length > 0 && result.content[0].type === "text") {
1498
+ const currentContent = result.content[0].text || '';
1499
+ result.content[0].text = `${currentContent}${feedbackResult.message}`;
1500
+ }
1501
+ else {
1502
+ result.content = [
1503
+ ...(result.content || []),
1504
+ {
1505
+ type: "text",
1506
+ text: feedbackResult.message
1507
+ }
1508
+ ];
1509
+ }
1510
+ // Mark that we've prompted (to prevent spam)
1511
+ await usageTracker.markFeedbackPrompted();
1504
1512
  }
1505
- // Mark that we've prompted (to prevent spam)
1506
- await usageTracker.markFeedbackPrompted();
1513
+ // Check if should prompt about Docker environment
1514
+ result = await processDockerPrompt(result, name);
1507
1515
  }
1508
- // Check if should prompt about Docker environment
1509
- result = await processDockerPrompt(result, name);
1510
1516
  }
1511
1517
  // If the caller sent parameters this tool does not support, Zod silently
1512
1518
  // strips them. Prepend a corrective warning so the model knows they were