Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
42 changes: 42 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ 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 { openWelcomePage } from './utils/open-browser.js';
import { isInTreatment } from './utils/ab-test.js';

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

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

// Open welcome page for claude-ai users (A/B test: 50/50 split)
// TODO: Add && configManager.isFirstRun() check after testing
if (currentClient.name === 'claude-ai' && !(global as any).disableOnboarding) {
const { inTreatment, isNew } = await isInTreatment('welcomePage');

if (isNew && inTreatment) {
try {
await openWelcomePage();
deferLog('info', 'Welcome page opened (A/B treatment)');
} catch (e) {
deferLog('warning', `Failed to open welcome page: ${e instanceof Error ? e.message : e}`);
}
} else if (isNew) {
deferLog('info', 'Welcome page skipped (A/B control)');
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

capture('run_server_mcp_initialized');
Expand Down Expand Up @@ -422,6 +441,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 +585,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 +634,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 +1055,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 +1087,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {

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

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

export interface ABTestConfig {
name: string; // Test name, used as config key prefix
variants: string[]; // e.g., ['control', 'treatment'] or ['A', 'B', 'C']
}

export interface ABTestResult {
variant: string;
isNewAssignment: boolean;
}

/**
* Get or create A/B test assignment for current user
* Assignment is deterministic based on clientId and persisted in config
*/
export async function getABTestVariant(test: ABTestConfig): Promise<ABTestResult> {
const configKey = `abTest_${test.name}`;
const existing = await configManager.getValue(configKey);

if (existing !== undefined && test.variants.includes(existing)) {
return { variant: existing, isNewAssignment: false };
}

// Assign based on clientId hash
const clientId = await configManager.getValue('clientId') || '';
const hash = simpleHash(clientId + test.name);
const variantIndex = hash % test.variants.length;
const variant = test.variants[variantIndex];

await configManager.setValue(configKey, variant);
return { variant, isNewAssignment: true };
}

/**
* Simple hash function for deterministic assignment
*/
function simpleHash(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);
}

/**
* Check if user is in treatment group (convenience for 2-variant tests)
*/
export async function isInTreatment(testName: string): Promise<{ inTreatment: boolean; isNew: boolean }> {
const result = await getABTestVariant({
name: testName,
variants: ['noOnboardingPage', 'sawOnboardingPage']
});
return {
inTreatment: result.variant === 'sawOnboardingPage',
isNew: result.isNewAssignment
};
}
14 changes: 14 additions & 0 deletions src/utils/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,19 @@ export const captureBase = async (captureURL: string, event: string, properties?
};
}

// Get A/B test assignment for welcome page experiment
let welcomePageContext = {};
try {
const variant = await configManager.getValue('abTest_welcomePage');
if (variant !== undefined) {
welcomePageContext = {
ab_welcome_page: variant
};
}
} catch (e) {
// Ignore errors getting A/B test status
}

// 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 Expand Up @@ -207,6 +220,7 @@ export const captureBase = async (captureURL: string, event: string, properties?
const eventProperties = {
...baseProperties,
...clientContext,
...welcomePageContext,
...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-v6/?ref=first-run';
await openBrowser(url);
}