Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion manifest.template.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "../../dist/mcpb-manifest.schema.json",
"manifest_version": "0.1",
"manifest_version": "0.3",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -name "manifest.template.json" -type f

Repository: wonderwhy-er/DesktopCommanderMCP

Length of output: 100


🏁 Script executed:

cat -n ./manifest.template.json

Repository: wonderwhy-er/DesktopCommanderMCP

Length of output: 8079


🏁 Script executed:

#!/bin/bash
# Verify the privacy policy URL is accessible
curl -sI "https://legal.desktopcommander.app/privacy_desktop_commander_mcp" 2>&1 | head -10

Repository: wonderwhy-er/DesktopCommanderMCP

Length of output: 354


Fix privacy policy URL before release.

The manifest version bump to 0.3 and privacy_policies addition align with the new features. However, the privacy policy URL https://legal.desktopcommander.app/privacy_desktop_commander_mcp returns a 404 error and is not accessible. Ensure the correct URL is provided or the endpoint is deployed before shipping this release.

🤖 Prompt for AI Agents
In manifest.template.json around line 3, the privacy_policies URL added for the
manifest version 0.3 points to
https://legal.desktopcommander.app/privacy_desktop_commander_mcp which returns
404; update the manifest to use the correct, reachable privacy policy URL (or
remove the entry temporarily) before release. Locate the privacy_policies field
in the template, replace the broken URL with the canonical deployed URL for
Desktop Commander’s privacy policy (or a stable placeholder like the
company/legal landing page) and verify the link returns 200; if the endpoint
isn’t yet deployed, revert the privacy_policies addition until the policy is
available to avoid shipping a broken link.

"name": "desktop-commander",
"display_name": "Desktop Commander",
"version": "{{VERSION}}",
Expand All @@ -18,6 +18,7 @@
"homepage": "https://github.com/wonderwhy-er/DesktopCommanderMCP",
"documentation": "https://github.com/wonderwhy-er/DesktopCommanderMCP/blob/main/FAQ.md",
"support": "https://github.com/wonderwhy-er/DesktopCommanderMCP/issues",
"privacy_policies": ["https://legal.desktopcommander.app/privacy_desktop_commander_mcp"],
"icon": "icon.png",
"server": {
"type": "node",
Expand Down
25 changes: 3 additions & 22 deletions scripts/build-mcpb.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ filesToCopy.forEach(file => {
}
});

// Step 6: Create package.json in bundle with production dependencies
// Step 6: Create package.json in bundle with production dependencies from main package.json
// This ensures MCPB bundle always has the same dependencies as the npm package
const bundlePackageJson = {
name: manifest.name,
version: manifest.version,
Expand All @@ -115,27 +116,7 @@ const bundlePackageJson = {
author: manifest.author,
license: manifest.license,
repository: manifest.repository,
dependencies: {
"@modelcontextprotocol/sdk": "^1.9.0",
"@opendocsg/pdf2md": "^0.2.2",
"@vscode/ripgrep": "^1.15.9",
"cross-fetch": "^4.1.0",
"exceljs": "^4.4.0",
"fastest-levenshtein": "^1.0.16",
"file-type": "^21.1.1",
"glob": "^10.3.10",
"isbinaryfile": "^5.0.4",
"md-to-pdf": "^5.2.5",
"pdf-lib": "^1.17.1",
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"sharp": "^0.34.5",
"unified": "^11.0.5",
"unpdf": "^1.4.0",
"zod": "^3.24.1",
"zod-to-json-schema": "^3.23.5"
}
dependencies: packageJson.dependencies // Use dependencies directly from package.json
};

fs.writeFileSync(
Expand Down
12 changes: 11 additions & 1 deletion src/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface ServerConfig {
fileReadLineLimit?: number; // Default line limit for file read operations (changed from character-based)
clientId?: string; // Unique client identifier for analytics
currentClient?: ClientInfo; // Current connected client information
[key: string]: any; // Allow for arbitrary configuration keys
[key: string]: any; // Allow for arbitrary configuration keys (including abTest_* keys)
}

export interface ClientInfo {
Expand All @@ -30,6 +30,7 @@ class ConfigManager {
private configPath: string;
private config: ServerConfig = {};
private initialized = false;
private _isFirstRun = false; // Track if this is the first run (config was just created)

constructor() {
// Get user's home directory
Expand All @@ -56,9 +57,11 @@ class ConfigManager {
// Load existing config
const configData = await fs.readFile(this.configPath, 'utf8');
this.config = JSON.parse(configData);
this._isFirstRun = false;
} catch (error) {
// Config file doesn't exist, create default
this.config = this.getDefaultConfig();
this._isFirstRun = true; // This is a first run!
await this.saveConfig();
}
this.config['version'] = VERSION;
Expand Down Expand Up @@ -221,6 +224,13 @@ class ConfigManager {
await this.saveConfig();
return { ...this.config };
}

/**
* Check if this is the first run (config file was just created)
*/
isFirstRun(): boolean {
return this._isFirstRun;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Export singleton instance
Expand Down
29 changes: 29 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { trackToolCall } from './utils/trackTools.js';
import { usageTracker } from './utils/usageTracker.js';
import { processDockerPrompt } from './utils/dockerPrompt.js';
import { toolHistory } from './utils/toolHistory.js';
import { handleWelcomePageOnboarding } from './utils/welcome-onboarding.js';

import { VERSION } from './version.js';
import { capture, capture_call_tool } from "./utils/capture.js";
Expand Down Expand Up @@ -130,6 +131,11 @@ server.setRequestHandler(InitializeRequestSchema, async (request: InitializeRequ

// Defer client connection message until after initialization
deferLog('info', `Client connected: ${currentClient.name} v${currentClient.version}`);

// Welcome page for new claude-ai users (A/B test controlled)
if (currentClient.name === 'claude-ai' && !(global as any).disableOnboarding) {
await handleWelcomePageOnboarding();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

capture('run_server_mcp_initialized');
Expand Down Expand Up @@ -422,6 +428,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
${PATH_GUIDANCE}
${CMD_PREFIX_DESCRIPTION}`,
inputSchema: zodToJsonSchema(CreateDirectoryArgsSchema),
annotations: {
title: "Create Directory",
readOnlyHint: false,
destructiveHint: false,
},
},
{
name: "list_directory",
Expand Down Expand Up @@ -561,6 +572,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
${PATH_GUIDANCE}
${CMD_PREFIX_DESCRIPTION}`,
inputSchema: zodToJsonSchema(StartSearchArgsSchema),
annotations: {
title: "Start Search",
readOnlyHint: true,
},
},
{
name: "get_more_search_results",
Expand Down Expand Up @@ -606,6 +621,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {

${CMD_PREFIX_DESCRIPTION}`,
inputSchema: zodToJsonSchema(StopSearchArgsSchema),
annotations: {
title: "Stop Search",
readOnlyHint: false,
destructiveHint: false,
},
},
{
name: "list_searches",
Expand Down Expand Up @@ -1022,6 +1042,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {

${CMD_PREFIX_DESCRIPTION}`,
inputSchema: zodToJsonSchema(GiveFeedbackArgsSchema),
annotations: {
title: "Give Feedback",
readOnlyHint: false,
openWorldHint: true,
},
},
{
name: "get_prompts",
Expand Down Expand Up @@ -1049,6 +1074,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {

${CMD_PREFIX_DESCRIPTION}`,
inputSchema: zodToJsonSchema(GetPromptsArgsSchema),
annotations: {
title: "Get Prompts",
readOnlyHint: true,
},
}
];

Expand Down
109 changes: 109 additions & 0 deletions src/utils/ab-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { configManager } from '../config-manager.js';
import { featureFlagManager } from './feature-flags.js';

/**
* A/B Test controlled feature flags
*
* Experiments are defined in remote feature flags JSON:
* {
* "flags": {
* "experiments": {
* "OnboardingPreTool": {
* "variants": ["noOnboardingPage", "showOnboardingPage"]
* }
* }
* }
* }
*
* Usage:
* if (await hasFeature('showOnboardingPage')) { ... }
*/

interface Experiment {
variants: string[];
}

// Cache for variant assignments (loaded once per session)
const variantCache: Record<string, string> = {};

/**
* Get experiments config from feature flags
*/
function getExperiments(): Record<string, Experiment> {
return featureFlagManager.get('experiments', {});
}

/**
* Get user's variant for an experiment (cached, deterministic)
*/
async function getVariant(experimentName: string): Promise<string | null> {
const experiments = getExperiments();
const experiment = experiments[experimentName];
if (!experiment?.variants?.length) return null;

// Check cache
if (variantCache[experimentName]) {
return variantCache[experimentName];
}

// Check persisted assignment
const configKey = `abTest_${experimentName}`;
const existing = await configManager.getValue(configKey);

if (existing && experiment.variants.includes(existing)) {
variantCache[experimentName] = existing;
return existing;
}

// New assignment based on clientId
const clientId = await configManager.getValue('clientId') || '';
const hash = hashCode(clientId + experimentName);
const variantIndex = hash % experiment.variants.length;
const variant = experiment.variants[variantIndex];

await configManager.setValue(configKey, variant);
variantCache[experimentName] = variant;
return variant;
}

/**
* Check if a feature (variant name) is enabled for current user
*/
export async function hasFeature(featureName: string): Promise<boolean> {
const experiments = getExperiments();
if (!experiments || typeof experiments !== 'object') return false;

for (const [expName, experiment] of Object.entries(experiments)) {
if (experiment?.variants?.includes(featureName)) {
const variant = await getVariant(expName);
return variant === featureName;
}
}
return false;
}

/**
* Get all A/B test assignments for analytics (reads from config)
*/
export async function getABTestAssignments(): Promise<Record<string, string>> {
const experiments = getExperiments();
const assignments: Record<string, string> = {};

for (const expName of Object.keys(experiments)) {
const configKey = `abTest_${expName}`;
const variant = await configManager.getValue(configKey);
if (variant) {
assignments[`ab_${expName}`] = variant;
}
}
return assignments;
}

function hashCode(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
6 changes: 6 additions & 0 deletions src/utils/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ export const captureBase = async (captureURL: string, event: string, properties?
};
}

// Track if user saw onboarding page
const sawOnboardingPage = await configManager.getValue('sawOnboardingPage');
if (sawOnboardingPage !== undefined) {
clientContext = { ...clientContext, saw_onboarding_page: sawOnboardingPage };
}

// Create a deep copy of properties to avoid modifying the original objects
// This ensures we don't alter error objects that are also returned to the AI
let sanitizedProperties;
Expand Down
46 changes: 46 additions & 0 deletions src/utils/open-browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { execFile, spawn } from 'child_process';
import os from 'os';
import { logToStderr } from './logger.js';

/**
* Open a URL in the default browser (cross-platform)
* Uses execFile/spawn with args array to avoid shell injection
*/
export async function openBrowser(url: string): Promise<void> {
const platform = os.platform();

return new Promise((resolve, reject) => {
const callback = (error: Error | null) => {
if (error) {
logToStderr('error', `Failed to open browser: ${error.message}`);
reject(error);
} else {
logToStderr('info', `Opened browser to: ${url}`);
resolve();
}
};

switch (platform) {
case 'darwin':
execFile('open', [url], callback);
break;
case 'win32':
// Windows 'start' is a shell builtin, use spawn with shell but pass URL as separate arg
spawn('cmd', ['/c', 'start', '', url], { shell: false }).on('close', (code) => {
code === 0 ? resolve() : reject(new Error(`Exit code ${code}`));
});
Comment on lines +28 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Windows code path bypasses callback and misses success logging.

The Windows implementation uses .on('close') instead of the callback, so the success log at line 18 ("Opened browser to: ${url}") never executes for Windows users. This creates inconsistent logging across platforms.

🔎 Proposed fix to unify error handling
     case 'win32':
-      // Windows 'start' is a shell builtin, use spawn with shell but pass URL as separate arg
-      spawn('cmd', ['/c', 'start', '', url], { shell: false }).on('close', (code) => {
-        code === 0 ? resolve() : reject(new Error(`Exit code ${code}`));
-      });
+      // Windows 'start' is a shell builtin, requires cmd.exe
+      spawn('cmd', ['/c', 'start', '', url], { shell: false }).on('close', (code) => {
+        callback(code === 0 ? null : new Error(`Exit code ${code}`));
+      });
       break;
🤖 Prompt for AI Agents
In src/utils/open-browser.ts around lines 28 to 31, the Windows spawn branch
uses .on('close') but never triggers the same success logging used by other
platforms, causing missing "Opened browser to: ${url}" output; update the
Windows branch so that when the child exits with code 0 you call the same
success path — log processLogger.info(`Opened browser to: ${url}`) and then
resolve(), and on non-zero exit reject with an Error(`Exit code ${code}`),
ensuring consistent logging and resolution behavior across platforms.

break;
default:
execFile('xdg-open', [url], callback);
break;
}
});
}

/**
* Open the Desktop Commander welcome page
*/
export async function openWelcomePage(): Promise<void> {
const url = 'https://desktopcommander.app/welcome/';
await openBrowser(url);
}
40 changes: 40 additions & 0 deletions src/utils/welcome-onboarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { configManager } from '../config-manager.js';
import { hasFeature } from './ab-test.js';
import { openWelcomePage } from './open-browser.js';
import { logToStderr } from './logger.js';

/**
* Handle welcome page display for new users (A/B test controlled)
*
* Only shows to:
* 1. New users (first run - config was just created)
* 2. Users in the 'showOnboardingPage' A/B variant
* 3. Haven't seen it yet
*/
export async function handleWelcomePageOnboarding(): Promise<void> {
// Only for brand new users (config just created)
if (!configManager.isFirstRun()) {
return;
}

// Check A/B test assignment
const shouldShow = await hasFeature('showOnboardingPage');
if (!shouldShow) {
logToStderr('debug', 'Welcome page skipped (A/B: noOnboardingPage)');
return;
}

// Double-check not already shown (safety)
const alreadyShown = await configManager.getValue('sawOnboardingPage');
if (alreadyShown) {
return;
}

try {
await openWelcomePage();
await configManager.setValue('sawOnboardingPage', true);
logToStderr('info', 'Welcome page opened');
} catch (e) {
logToStderr('warning', `Failed to open welcome page: ${e instanceof Error ? e.message : e}`);
}
}
Loading