Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
20 changes: 11 additions & 9 deletions packages/cli/src/commands/mcp/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe('mcp add command', () => {
mockSetValue = vi.fn();
mockWriteStderrLine.mockClear();
mockedLoadSettings.mockReturnValue({
forScope: () => ({ settings: {} }),
forScope: () => ({ settings: {}, originalSettings: {} }),
setValue: mockSetValue,
workspace: { path: '/path/to/project' },
user: { path: '/home/user' },
Expand Down Expand Up @@ -175,7 +175,7 @@ describe('mcp add command', () => {
const setupMocks = (cwd: string, workspacePath: string) => {
vi.spyOn(process, 'cwd').mockReturnValue(cwd);
mockedLoadSettings.mockReturnValue({
forScope: () => ({ settings: {} }),
forScope: () => ({ settings: {}, originalSettings: {} }),
setValue: mockSetValue,
workspace: { path: workspacePath },
user: { path: '/home/user' },
Expand Down Expand Up @@ -325,15 +325,17 @@ describe('mcp add command', () => {
const updatedArgs = ['new'];

beforeEach(() => {
const serverData = {
mcpServers: {
[serverName]: {
command: initialCommand,
},
},
};
mockedLoadSettings.mockReturnValue({
forScope: () => ({
settings: {
mcpServers: {
[serverName]: {
command: initialCommand,
},
},
},
settings: serverData,
originalSettings: serverData,
}),
setValue: mockSetValue,
workspace: { path: '/path/to/project' },
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/commands/mcp/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,11 @@ async function addMcpServer(
break;
}

@wenshao wenshao May 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] existingSettings = settings.forScope(scope).settings reads the already-resolved copy (via resolveEnvVarsInObject). When setValueFullSave writes this resolved value into originalSettings (the pre-resolution copy), ${ENV_VAR} tokens in mcpServers will be replaced with actual values and persisted to disk. Consider reading from originalSettings to preserve the original references:

Suggested change
const existingSettings = settings.forScope(settingsScope).originalSettings;

— DeepSeek/deepseek-v4-pro via Qwen Code /review

const existingSettings = settings.forScope(settingsScope).settings;
const mcpServers = existingSettings.mcpServers || {};
const existingSettings = settings.forScope(settingsScope).originalSettings;
const mcpServers = (existingSettings.mcpServers || {}) as Record<
string,
MCPServerConfig
>;

const isExistingServer = !!mcpServers[name];
if (isExistingServer) {
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/commands/mcp/remove.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ describe('mcp remove command', () => {
},
};
mockedLoadSettings.mockReturnValue({
forScope: () => ({ settings: mockSettings }),
workspace: { path: '/home/user/project' },
user: { path: '/home/user' },
forScope: () => ({
settings: mockSettings,
originalSettings: mockSettings,
}),
setValue: mockSetValue,
});
mockWriteStdoutLine.mockClear();
Expand Down
17 changes: 14 additions & 3 deletions packages/cli/src/commands/mcp/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// File for 'qwen mcp remove' command
import type { CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { writeStdoutLine } from '../../utils/stdioHelpers.js';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
import { MCPOAuthTokenStorage } from '@qwen-code/qwen-code-core';

async function removeMcpServer(
Expand All @@ -20,9 +20,20 @@ async function removeMcpServer(
const settingsScope =
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
const settings = loadSettings();
const inHome = settings.workspace.path === settings.user.path;

const existingSettings = settings.forScope(settingsScope).settings;
const mcpServers = existingSettings.mcpServers || {};
if (scope === 'project' && inHome) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The new inHome guard here mirrors the one in add.ts, but remove.test.ts has no test coverage for it. In add.test.ts, the analogous guard is tested (see the 'when in the home directory' describe block around line 233, which covers: default scope in home dir, explicit --scope project rejection, and explicit --scope user passthrough).

Consider adding similar tests to remove.test.ts — at minimum a case where workspace.path === user.path and --scope project is passed, verifying that process.exit(1) is called and setValue is never invoked. Without this, a future refactor that breaks the guard in remove.ts would go undetected.

writeStderrLine(
'Error: Please use --scope user to edit settings in the home directory.',
);
process.exit(1);
}

const existingSettings = settings.forScope(settingsScope).originalSettings;
const mcpServers = (existingSettings.mcpServers || {}) as Record<
string,
Record<string, unknown>
>;

if (!mcpServers[name]) {
writeStdoutLine(`Server "${name}" not found in ${scope} settings.`);
Expand Down
14 changes: 13 additions & 1 deletion packages/cli/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,8 +470,20 @@ export class LoadedSettings {
value = stripRuntimeSnapshotPrefix(value);
}
const settingsFile = this.forScope(scope);
setNestedPropertySafe(settingsFile.settings, key, value);
// Write the raw value to originalSettings (preserves ${VAR} tokens for
// settings like mcpServers that may contain env-var references).
setNestedPropertySafe(settingsFile.originalSettings, key, value);
// Write the env-resolved version to .settings so that consumers of
// settings.merged see actual values, not literal ${VAR} strings.
const resolved = resolveEnvVarsInObject(
{ [key]: value } as Settings,
getHomeEnvFallbackVars(),
);
setNestedPropertySafe(
settingsFile.settings,
key,
(resolved as Record<string, unknown>)[key],
);
this._merged = this.computeMergedSettings();
const replacePath = key === 'mcpServers' ? key.split('.') : [];
saveSettings(settingsFile, createSettingsUpdate(key, value), replacePath);
Expand Down
Loading