Skip to content

Commit cc55d78

Browse files
authored
Merge pull request #1752 from QwenLM/fix/mcp-oauth-branding-updates
fix(mcp): improve MCP server management and authentication
2 parents a5d2ca9 + 7c53995 commit cc55d78

30 files changed

Lines changed: 302 additions & 130 deletions

integration-tests/mcp_server_cyclic_schema.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* schema object which has stricter typing and recursion restrictions.
1515
* If this test fails, it's likely because either the GenAI SDK or Gemini API
1616
* has become more restrictive about the type of tool parameter schemas that
17-
* are accepted. If this occurs: Gemini CLI previously attempted to detect
17+
* are accepted. If this occurs: Qwen Code previously attempted to detect
1818
* such tools and proactively remove them from the set of tools provided in
1919
* the Gemini API call (as FunctionDeclaration objects). It may be appropriate
2020
* to resurrect that behavior but note that it's difficult to keep the

packages/cli/src/commands/mcp/add.test.ts

Lines changed: 74 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -65,22 +65,50 @@ describe('mcp add command', () => {
6565
});
6666
});
6767

68-
it('should add a stdio server to project settings', async () => {
68+
it('should add a stdio server to user settings by default', async () => {
6969
await parser.parseAsync(
7070
'add my-server /path/to/server arg1 arg2 -e FOO=bar',
7171
);
7272

73-
expect(mockSetValue).toHaveBeenCalledWith(
74-
SettingScope.Workspace,
75-
'mcpServers',
76-
{
77-
'my-server': {
78-
command: '/path/to/server',
79-
args: ['arg1', 'arg2'],
80-
env: { FOO: 'bar' },
81-
},
73+
expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', {
74+
'my-server': {
75+
command: '/path/to/server',
76+
args: ['arg1', 'arg2'],
77+
env: { FOO: 'bar' },
78+
},
79+
});
80+
});
81+
82+
it('should auto-detect http transport when commandOrUrl is an https URL', async () => {
83+
await parser.parseAsync('add http-server https://example.com/mcp');
84+
85+
expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', {
86+
'http-server': {
87+
httpUrl: 'https://example.com/mcp',
8288
},
89+
});
90+
});
91+
92+
it('should auto-detect http transport when commandOrUrl is an http URL', async () => {
93+
await parser.parseAsync('add http-server http://localhost:8080/mcp');
94+
95+
expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', {
96+
'http-server': {
97+
httpUrl: 'http://localhost:8080/mcp',
98+
},
99+
});
100+
});
101+
102+
it('should respect explicit transport even when commandOrUrl is a URL', async () => {
103+
await parser.parseAsync(
104+
'add --transport sse sse-server https://example.com/sse-endpoint',
83105
);
106+
107+
expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', {
108+
'sse-server': {
109+
url: 'https://example.com/sse-endpoint',
110+
},
111+
});
84112
});
85113

86114
it('should add an sse server to user settings', async () => {
@@ -96,55 +124,43 @@ describe('mcp add command', () => {
96124
});
97125
});
98126

99-
it('should add an http server to project settings', async () => {
127+
it('should add an http server to user settings by default', async () => {
100128
await parser.parseAsync(
101129
'add --transport http http-server https://example.com/mcp -H "Authorization: Bearer your-token"',
102130
);
103131

104-
expect(mockSetValue).toHaveBeenCalledWith(
105-
SettingScope.Workspace,
106-
'mcpServers',
107-
{
108-
'http-server': {
109-
httpUrl: 'https://example.com/mcp',
110-
headers: { Authorization: 'Bearer your-token' },
111-
},
132+
expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', {
133+
'http-server': {
134+
httpUrl: 'https://example.com/mcp',
135+
headers: { Authorization: 'Bearer your-token' },
112136
},
113-
);
137+
});
114138
});
115139

116140
it('should handle MCP server args with -- separator', async () => {
117141
await parser.parseAsync(
118142
'add my-server npx -- -y http://example.com/some-package',
119143
);
120144

121-
expect(mockSetValue).toHaveBeenCalledWith(
122-
SettingScope.Workspace,
123-
'mcpServers',
124-
{
125-
'my-server': {
126-
command: 'npx',
127-
args: ['-y', 'http://example.com/some-package'],
128-
},
145+
expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', {
146+
'my-server': {
147+
command: 'npx',
148+
args: ['-y', 'http://example.com/some-package'],
129149
},
130-
);
150+
});
131151
});
132152

133153
it('should handle unknown options as MCP server args', async () => {
134154
await parser.parseAsync(
135155
'add test-server npx -y http://example.com/some-package',
136156
);
137157

138-
expect(mockSetValue).toHaveBeenCalledWith(
139-
SettingScope.Workspace,
140-
'mcpServers',
141-
{
142-
'test-server': {
143-
command: 'npx',
144-
args: ['-y', 'http://example.com/some-package'],
145-
},
158+
expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', {
159+
'test-server': {
160+
command: 'npx',
161+
args: ['-y', 'http://example.com/some-package'],
146162
},
147-
);
163+
});
148164
});
149165

150166
describe('when handling scope and directory', () => {
@@ -166,10 +182,10 @@ describe('mcp add command', () => {
166182
setupMocks('/path/to/project', '/path/to/project');
167183
});
168184

169-
it('should use project scope by default', async () => {
185+
it('should use user scope by default', async () => {
170186
await parser.parseAsync(`add ${serverName} ${command}`);
171187
expect(mockSetValue).toHaveBeenCalledWith(
172-
SettingScope.Workspace,
188+
SettingScope.User,
173189
'mcpServers',
174190
expect.any(Object),
175191
);
@@ -199,10 +215,10 @@ describe('mcp add command', () => {
199215
setupMocks('/path/to/project/subdir', '/path/to/project');
200216
});
201217

202-
it('should use project scope by default', async () => {
218+
it('should use user scope by default', async () => {
203219
await parser.parseAsync(`add ${serverName} ${command}`);
204220
expect(mockSetValue).toHaveBeenCalledWith(
205-
SettingScope.Workspace,
221+
SettingScope.User,
206222
'mcpServers',
207223
expect.any(Object),
208224
);
@@ -214,22 +230,14 @@ describe('mcp add command', () => {
214230
setupMocks('/home/user', '/home/user');
215231
});
216232

217-
it('should show an error by default', async () => {
218-
const mockProcessExit = vi
219-
.spyOn(process, 'exit')
220-
.mockImplementation((() => {
221-
throw new Error('process.exit called');
222-
}) as (code?: number) => never);
223-
224-
await expect(
225-
parser.parseAsync(`add ${serverName} ${command}`),
226-
).rejects.toThrow('process.exit called');
227-
228-
expect(mockWriteStderrLine).toHaveBeenCalledWith(
229-
'Error: Please use --scope user to edit settings in the home directory.',
233+
it('should use user scope by default without error', async () => {
234+
await parser.parseAsync(`add ${serverName} ${command}`);
235+
expect(mockSetValue).toHaveBeenCalledWith(
236+
SettingScope.User,
237+
'mcpServers',
238+
expect.any(Object),
230239
);
231-
expect(mockProcessExit).toHaveBeenCalledWith(1);
232-
expect(mockSetValue).not.toHaveBeenCalled();
240+
expect(mockWriteStderrLine).not.toHaveBeenCalled();
233241
});
234242

235243
it('should show an error when --scope=project is used explicitly', async () => {
@@ -266,16 +274,16 @@ describe('mcp add command', () => {
266274
setupMocks('/home/user/some/dir', '/home/user/some/dir');
267275
});
268276

269-
it('should use project scope by default', async () => {
277+
it('should use user scope by default', async () => {
270278
await parser.parseAsync(`add ${serverName} ${command}`);
271279
expect(mockSetValue).toHaveBeenCalledWith(
272-
SettingScope.Workspace,
280+
SettingScope.User,
273281
'mcpServers',
274282
expect.any(Object),
275283
);
276284
});
277285

278-
it('should write to the WORKSPACE scope, not the USER scope', async () => {
286+
it('should write to the USER scope by default', async () => {
279287
await parser.parseAsync(`add my-new-server echo`);
280288

281289
// We expect setValue to be called once.
@@ -284,8 +292,8 @@ describe('mcp add command', () => {
284292
// We get the scope that setValue was called with.
285293
const calledScope = mockSetValue.mock.calls[0][0];
286294

287-
// We assert that the scope was Workspace, not User.
288-
expect(calledScope).toBe(SettingScope.Workspace);
295+
// We assert that the scope was User by default.
296+
expect(calledScope).toBe(SettingScope.User);
289297
});
290298
});
291299

@@ -294,10 +302,10 @@ describe('mcp add command', () => {
294302
setupMocks('/tmp/foo', '/tmp/foo');
295303
});
296304

297-
it('should use project scope by default', async () => {
305+
it('should use user scope by default', async () => {
298306
await parser.parseAsync(`add ${serverName} ${command}`);
299307
expect(mockSetValue).toHaveBeenCalledWith(
300-
SettingScope.Workspace,
308+
SettingScope.User,
301309
'mcpServers',
302310
expect.any(Object),
303311
);
@@ -328,12 +336,12 @@ describe('mcp add command', () => {
328336
});
329337
});
330338

331-
it('should update the existing server in the project scope', async () => {
339+
it('should update the existing server in the user scope by default', async () => {
332340
await parser.parseAsync(
333341
`add ${serverName} ${updatedCommand} ${updatedArgs.join(' ')}`,
334342
);
335343
expect(mockSetValue).toHaveBeenCalledWith(
336-
SettingScope.Workspace,
344+
SettingScope.User,
337345
'mcpServers',
338346
expect.objectContaining({
339347
[serverName]: expect.objectContaining({

packages/cli/src/commands/mcp/add.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
// File for 'gemini mcp add' command
7+
// File for 'qwen mcp add' command
88
import type { CommandModule } from 'yargs';
99
import { loadSettings, SettingScope } from '../../config/settings.js';
1010
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
@@ -159,14 +159,14 @@ export const addCommand: CommandModule = {
159159
alias: 's',
160160
describe: 'Configuration scope (user or project)',
161161
type: 'string',
162-
default: 'project',
162+
default: 'user',
163163
choices: ['user', 'project'],
164164
})
165165
.option('transport', {
166166
alias: 't',
167-
describe: 'Transport type (stdio, sse, http)',
167+
describe:
168+
'Transport type (stdio, sse, http). Auto-detected from URL if not specified.',
168169
type: 'string',
169-
default: 'stdio',
170170
choices: ['stdio', 'sse', 'http'],
171171
})
172172
.option('env', {
@@ -211,6 +211,20 @@ export const addCommand: CommandModule = {
211211
const existingArgs = (argv['args'] as Array<string | number>) || [];
212212
argv['args'] = [...existingArgs, ...(argv['--'] as string[])];
213213
}
214+
215+
// Auto-detect transport from URL if not explicitly specified
216+
if (!argv['transport']) {
217+
const commandOrUrl = argv['commandOrUrl'] as string;
218+
if (
219+
commandOrUrl &&
220+
(commandOrUrl.startsWith('http://') ||
221+
commandOrUrl.startsWith('https://'))
222+
) {
223+
argv['transport'] = 'http';
224+
} else {
225+
argv['transport'] = 'stdio';
226+
}
227+
}
214228
}),
215229
handler: async (argv) => {
216230
await addMcpServer(

packages/cli/src/commands/mcp/list.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
// File for 'gemini mcp list' command
7+
// File for 'qwen mcp list' command
88
import type { CommandModule } from 'yargs';
99
import { loadSettings } from '../../config/settings.js';
1010
import { writeStdoutLine } from '../../utils/stdioHelpers.js';

packages/cli/src/commands/mcp/remove.test.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { removeCommand } from './remove.js';
1111

1212
const mockWriteStdoutLine = vi.hoisted(() => vi.fn());
1313
const mockWriteStderrLine = vi.hoisted(() => vi.fn());
14+
const mockDeleteCredentials = vi.hoisted(() => vi.fn());
1415

1516
vi.mock('../../utils/stdioHelpers.js', () => ({
1617
writeStdoutLine: mockWriteStdoutLine,
@@ -35,6 +36,17 @@ vi.mock('../../config/settings.js', async () => {
3536
};
3637
});
3738

39+
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
40+
const actual =
41+
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
42+
return {
43+
...actual,
44+
MCPOAuthTokenStorage: vi.fn(() => ({
45+
deleteCredentials: mockDeleteCredentials,
46+
})),
47+
};
48+
});
49+
3850
const mockedLoadSettings = loadSettings as vi.Mock;
3951

4052
describe('mcp remove command', () => {
@@ -59,24 +71,45 @@ describe('mcp remove command', () => {
5971
setValue: mockSetValue,
6072
});
6173
mockWriteStdoutLine.mockClear();
74+
mockDeleteCredentials.mockClear();
75+
});
76+
77+
it('should remove a server from user settings by default', async () => {
78+
await parser.parseAsync('remove test-server');
79+
80+
expect(mockSetValue).toHaveBeenCalledWith(
81+
SettingScope.User,
82+
'mcpServers',
83+
{},
84+
);
6285
});
6386

64-
it('should remove a server from project settings', async () => {
87+
it('should clean up OAuth tokens when removing a server', async () => {
88+
await parser.parseAsync('remove test-server');
89+
90+
expect(mockDeleteCredentials).toHaveBeenCalledWith('test-server');
91+
});
92+
93+
it('should not fail if OAuth token cleanup fails', async () => {
94+
mockDeleteCredentials.mockRejectedValue(new Error('cleanup failed'));
95+
6596
await parser.parseAsync('remove test-server');
6697

98+
// Server should still be removed from settings despite token cleanup failure
6799
expect(mockSetValue).toHaveBeenCalledWith(
68-
SettingScope.Workspace,
100+
SettingScope.User,
69101
'mcpServers',
70102
{},
71103
);
72104
});
73105

74-
it('should show a message if server not found', async () => {
106+
it('should not clean up OAuth tokens if server not found', async () => {
75107
await parser.parseAsync('remove non-existent-server');
76108

77109
expect(mockSetValue).not.toHaveBeenCalled();
110+
expect(mockDeleteCredentials).not.toHaveBeenCalled();
78111
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
79-
'Server "non-existent-server" not found in project settings.',
112+
'Server "non-existent-server" not found in user settings.',
80113
);
81114
});
82115
});

0 commit comments

Comments
 (0)