diff --git a/integration-tests/mcp_server_cyclic_schema.test.ts b/integration-tests/mcp_server_cyclic_schema.test.ts index 367e5cd56..40963a240 100644 --- a/integration-tests/mcp_server_cyclic_schema.test.ts +++ b/integration-tests/mcp_server_cyclic_schema.test.ts @@ -14,7 +14,7 @@ * schema object which has stricter typing and recursion restrictions. * If this test fails, it's likely because either the GenAI SDK or Gemini API * has become more restrictive about the type of tool parameter schemas that - * are accepted. If this occurs: Gemini CLI previously attempted to detect + * are accepted. If this occurs: Qwen Code previously attempted to detect * such tools and proactively remove them from the set of tools provided in * the Gemini API call (as FunctionDeclaration objects). It may be appropriate * to resurrect that behavior but note that it's difficult to keep the diff --git a/packages/cli/src/commands/mcp/add.test.ts b/packages/cli/src/commands/mcp/add.test.ts index 19a069975..e46a431b4 100644 --- a/packages/cli/src/commands/mcp/add.test.ts +++ b/packages/cli/src/commands/mcp/add.test.ts @@ -65,22 +65,18 @@ describe('mcp add command', () => { }); }); - it('should add a stdio server to project settings', async () => { + it('should add a stdio server to user settings by default', async () => { await parser.parseAsync( 'add my-server /path/to/server arg1 arg2 -e FOO=bar', ); - expect(mockSetValue).toHaveBeenCalledWith( - SettingScope.Workspace, - 'mcpServers', - { - 'my-server': { - command: '/path/to/server', - args: ['arg1', 'arg2'], - env: { FOO: 'bar' }, - }, + expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { + 'my-server': { + command: '/path/to/server', + args: ['arg1', 'arg2'], + env: { FOO: 'bar' }, }, - ); + }); }); it('should add an sse server to user settings', async () => { @@ -96,21 +92,17 @@ describe('mcp add command', () => { }); }); - it('should add an http server to project settings', async () => { + it('should add an http server to user settings by default', async () => { await parser.parseAsync( 'add --transport http http-server https://example.com/mcp -H "Authorization: Bearer your-token"', ); - expect(mockSetValue).toHaveBeenCalledWith( - SettingScope.Workspace, - 'mcpServers', - { - 'http-server': { - httpUrl: 'https://example.com/mcp', - headers: { Authorization: 'Bearer your-token' }, - }, + expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { + 'http-server': { + httpUrl: 'https://example.com/mcp', + headers: { Authorization: 'Bearer your-token' }, }, - ); + }); }); it('should handle MCP server args with -- separator', async () => { @@ -118,16 +110,12 @@ describe('mcp add command', () => { 'add my-server npx -- -y http://example.com/some-package', ); - expect(mockSetValue).toHaveBeenCalledWith( - SettingScope.Workspace, - 'mcpServers', - { - 'my-server': { - command: 'npx', - args: ['-y', 'http://example.com/some-package'], - }, + expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { + 'my-server': { + command: 'npx', + args: ['-y', 'http://example.com/some-package'], }, - ); + }); }); it('should handle unknown options as MCP server args', async () => { @@ -135,16 +123,12 @@ describe('mcp add command', () => { 'add test-server npx -y http://example.com/some-package', ); - expect(mockSetValue).toHaveBeenCalledWith( - SettingScope.Workspace, - 'mcpServers', - { - 'test-server': { - command: 'npx', - args: ['-y', 'http://example.com/some-package'], - }, + expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { + 'test-server': { + command: 'npx', + args: ['-y', 'http://example.com/some-package'], }, - ); + }); }); describe('when handling scope and directory', () => { @@ -166,10 +150,10 @@ describe('mcp add command', () => { setupMocks('/path/to/project', '/path/to/project'); }); - it('should use project scope by default', async () => { + it('should use user scope by default', async () => { await parser.parseAsync(`add ${serverName} ${command}`); expect(mockSetValue).toHaveBeenCalledWith( - SettingScope.Workspace, + SettingScope.User, 'mcpServers', expect.any(Object), ); @@ -199,10 +183,10 @@ describe('mcp add command', () => { setupMocks('/path/to/project/subdir', '/path/to/project'); }); - it('should use project scope by default', async () => { + it('should use user scope by default', async () => { await parser.parseAsync(`add ${serverName} ${command}`); expect(mockSetValue).toHaveBeenCalledWith( - SettingScope.Workspace, + SettingScope.User, 'mcpServers', expect.any(Object), ); @@ -214,22 +198,14 @@ describe('mcp add command', () => { setupMocks('/home/user', '/home/user'); }); - it('should show an error by default', async () => { - const mockProcessExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => { - throw new Error('process.exit called'); - }) as (code?: number) => never); - - await expect( - parser.parseAsync(`add ${serverName} ${command}`), - ).rejects.toThrow('process.exit called'); - - expect(mockWriteStderrLine).toHaveBeenCalledWith( - 'Error: Please use --scope user to edit settings in the home directory.', + it('should use user scope by default without error', async () => { + await parser.parseAsync(`add ${serverName} ${command}`); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.User, + 'mcpServers', + expect.any(Object), ); - expect(mockProcessExit).toHaveBeenCalledWith(1); - expect(mockSetValue).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).not.toHaveBeenCalled(); }); it('should show an error when --scope=project is used explicitly', async () => { @@ -266,16 +242,16 @@ describe('mcp add command', () => { setupMocks('/home/user/some/dir', '/home/user/some/dir'); }); - it('should use project scope by default', async () => { + it('should use user scope by default', async () => { await parser.parseAsync(`add ${serverName} ${command}`); expect(mockSetValue).toHaveBeenCalledWith( - SettingScope.Workspace, + SettingScope.User, 'mcpServers', expect.any(Object), ); }); - it('should write to the WORKSPACE scope, not the USER scope', async () => { + it('should write to the USER scope by default', async () => { await parser.parseAsync(`add my-new-server echo`); // We expect setValue to be called once. @@ -284,8 +260,8 @@ describe('mcp add command', () => { // We get the scope that setValue was called with. const calledScope = mockSetValue.mock.calls[0][0]; - // We assert that the scope was Workspace, not User. - expect(calledScope).toBe(SettingScope.Workspace); + // We assert that the scope was User by default. + expect(calledScope).toBe(SettingScope.User); }); }); @@ -294,10 +270,10 @@ describe('mcp add command', () => { setupMocks('/tmp/foo', '/tmp/foo'); }); - it('should use project scope by default', async () => { + it('should use user scope by default', async () => { await parser.parseAsync(`add ${serverName} ${command}`); expect(mockSetValue).toHaveBeenCalledWith( - SettingScope.Workspace, + SettingScope.User, 'mcpServers', expect.any(Object), ); @@ -328,12 +304,12 @@ describe('mcp add command', () => { }); }); - it('should update the existing server in the project scope', async () => { + it('should update the existing server in the user scope by default', async () => { await parser.parseAsync( `add ${serverName} ${updatedCommand} ${updatedArgs.join(' ')}`, ); expect(mockSetValue).toHaveBeenCalledWith( - SettingScope.Workspace, + SettingScope.User, 'mcpServers', expect.objectContaining({ [serverName]: expect.objectContaining({ diff --git a/packages/cli/src/commands/mcp/add.ts b/packages/cli/src/commands/mcp/add.ts index bbaf79961..65de64981 100644 --- a/packages/cli/src/commands/mcp/add.ts +++ b/packages/cli/src/commands/mcp/add.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -// File for 'gemini mcp add' command +// File for 'qwen mcp add' command import type { CommandModule } from 'yargs'; import { loadSettings, SettingScope } from '../../config/settings.js'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; @@ -159,7 +159,7 @@ export const addCommand: CommandModule = { alias: 's', describe: 'Configuration scope (user or project)', type: 'string', - default: 'project', + default: 'user', choices: ['user', 'project'], }) .option('transport', { diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index b754b2754..b4e71e345 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -// File for 'gemini mcp list' command +// File for 'qwen mcp list' command import type { CommandModule } from 'yargs'; import { loadSettings } from '../../config/settings.js'; import { writeStdoutLine } from '../../utils/stdioHelpers.js'; diff --git a/packages/cli/src/commands/mcp/remove.test.ts b/packages/cli/src/commands/mcp/remove.test.ts index 4bae8a6ed..e2fb6d6d2 100644 --- a/packages/cli/src/commands/mcp/remove.test.ts +++ b/packages/cli/src/commands/mcp/remove.test.ts @@ -11,6 +11,7 @@ import { removeCommand } from './remove.js'; const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); const mockWriteStderrLine = vi.hoisted(() => vi.fn()); +const mockDeleteCredentials = vi.hoisted(() => vi.fn()); vi.mock('../../utils/stdioHelpers.js', () => ({ writeStdoutLine: mockWriteStdoutLine, @@ -35,6 +36,17 @@ vi.mock('../../config/settings.js', async () => { }; }); +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + MCPOAuthTokenStorage: vi.fn(() => ({ + deleteCredentials: mockDeleteCredentials, + })), + }; +}); + const mockedLoadSettings = loadSettings as vi.Mock; describe('mcp remove command', () => { @@ -59,24 +71,45 @@ describe('mcp remove command', () => { setValue: mockSetValue, }); mockWriteStdoutLine.mockClear(); + mockDeleteCredentials.mockClear(); }); - it('should remove a server from project settings', async () => { + it('should remove a server from user settings by default', async () => { await parser.parseAsync('remove test-server'); expect(mockSetValue).toHaveBeenCalledWith( - SettingScope.Workspace, + SettingScope.User, 'mcpServers', {}, ); }); - it('should show a message if server not found', async () => { + it('should clean up OAuth tokens when removing a server', async () => { + await parser.parseAsync('remove test-server'); + + expect(mockDeleteCredentials).toHaveBeenCalledWith('test-server'); + }); + + it('should not fail if OAuth token cleanup fails', async () => { + mockDeleteCredentials.mockRejectedValue(new Error('cleanup failed')); + + await parser.parseAsync('remove test-server'); + + // Server should still be removed from settings despite token cleanup failure + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.User, + 'mcpServers', + {}, + ); + }); + + it('should not clean up OAuth tokens if server not found', async () => { await parser.parseAsync('remove non-existent-server'); expect(mockSetValue).not.toHaveBeenCalled(); + expect(mockDeleteCredentials).not.toHaveBeenCalled(); expect(mockWriteStdoutLine).toHaveBeenCalledWith( - 'Server "non-existent-server" not found in project settings.', + 'Server "non-existent-server" not found in user settings.', ); }); }); diff --git a/packages/cli/src/commands/mcp/remove.ts b/packages/cli/src/commands/mcp/remove.ts index 87d73cf6c..3de482d8d 100644 --- a/packages/cli/src/commands/mcp/remove.ts +++ b/packages/cli/src/commands/mcp/remove.ts @@ -4,10 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -// File for 'gemini mcp remove' command +// 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 { MCPOAuthTokenStorage } from '@qwen-code/qwen-code-core'; async function removeMcpServer( name: string, @@ -32,6 +33,14 @@ async function removeMcpServer( settings.setValue(settingsScope, 'mcpServers', mcpServers); + // Clean up any stored OAuth tokens for this server + try { + const tokenStorage = new MCPOAuthTokenStorage(); + await tokenStorage.deleteCredentials(name); + } catch { + // Token cleanup is best-effort; don't fail the remove operation + } + writeStdoutLine(`Server "${name}" removed from ${scope} settings.`); } @@ -40,7 +49,7 @@ export const removeCommand: CommandModule = { describe: 'Remove a server', builder: (yargs) => yargs - .usage('Usage: gemini mcp remove [options] ') + .usage('Usage: qwen mcp remove [options] ') .positional('name', { describe: 'Name of the server', type: 'string', @@ -50,7 +59,7 @@ export const removeCommand: CommandModule = { alias: 's', describe: 'Configuration scope (user or project)', type: 'string', - default: 'project', + default: 'user', choices: ['user', 'project'], }), handler: async (argv) => { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index c2f75f06d..08c0631a8 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -336,7 +336,7 @@ export async function main() { } // We are now past the logic handling potentially launching a child process - // to run Gemini CLI. It is now safe to perform expensive initialization that + // to run Qwen Code. It is now safe to perform expensive initialization that // may have side effects. // Initialize output language file before config loads to ensure it's included in context diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index cf5d523a7..f0054b397 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1055,9 +1055,6 @@ export default { // MCP Status // ============================================================================ 'No MCP servers configured.': 'Keine MCP-Server konfiguriert.', - 'Please view MCP documentation in your browser:': - 'Bitte sehen Sie die MCP-Dokumentation in Ihrem Browser:', - 'or use the cli /docs command': 'oder verwenden Sie den CLI-Befehl /docs', '⏳ MCP servers are starting up ({{count}} initializing)...': '⏳ MCP-Server werden gestartet ({{count}} werden initialisiert)...', 'Note: First startup may take longer. Tool availability will update automatically.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index e5236c1e3..79af44452 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1042,9 +1042,6 @@ export default { // MCP Status // ============================================================================ 'No MCP servers configured.': 'No MCP servers configured.', - 'Please view MCP documentation in your browser:': - 'Please view MCP documentation in your browser:', - 'or use the cli /docs command': 'or use the cli /docs command', '⏳ MCP servers are starting up ({{count}} initializing)...': '⏳ MCP servers are starting up ({{count}} initializing)...', 'Note: First startup may take longer. Tool availability will update automatically.': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 2cfad0700..a9a27c107 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -746,9 +746,6 @@ export default { 'Press Ctrl+D again to exit.': 'Ctrl+D をもう一度押すと終了します', 'Press Esc again to clear.': 'Esc をもう一度押すとクリアします', // MCP Status - 'Please view MCP documentation in your browser:': - 'ブラウザでMCPドキュメントを確認してください:', - 'or use the cli /docs command': 'または CLI の /docs コマンドを使用', '⏳ MCP servers are starting up ({{count}} initializing)...': '⏳ MCPサーバーを起動中({{count}} 初期化中)...', 'Note: First startup may take longer. Tool availability will update automatically.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 83a491126..1f085dfcf 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1065,9 +1065,6 @@ export default { // MCP Status // ============================================================================ 'No MCP servers configured.': 'Nenhum servidor MCP configurado.', - 'Please view MCP documentation in your browser:': - 'Veja a documentação do MCP no seu navegador:', - 'or use the cli /docs command': 'ou use o comando cli /docs', '⏳ MCP servers are starting up ({{count}} initializing)...': '⏳ Servidores MCP estão iniciando ({{count}} inicializando)...', 'Note: First startup may take longer. Tool availability will update automatically.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 8b484aac1..2a3ad1385 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1057,9 +1057,6 @@ export default { // Статус MCP // ============================================================================ 'No MCP servers configured.': 'Не настроено MCP-серверов.', - 'Please view MCP documentation in your browser:': - 'Пожалуйста, просмотрите документацию MCP в браузере:', - 'or use the cli /docs command': 'или используйте команду cli /docs', '⏳ MCP servers are starting up ({{count}} initializing)...': '⏳ MCP-серверы запускаются ({{count}} инициализируется)...', 'Note: First startup may take longer. Tool availability will update automatically.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 713c6ffee..10530a4ac 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -985,9 +985,6 @@ export default { // MCP Status // ============================================================================ 'No MCP servers configured.': '未配置 MCP 服务器', - 'Please view MCP documentation in your browser:': - '请在浏览器中查看 MCP 文档:', - 'or use the cli /docs command': '或使用 cli /docs 命令', '⏳ MCP servers are starting up ({{count}} initializing)...': '⏳ MCP 服务器正在启动({{count}} 个正在初始化)...', 'Note: First startup may take longer. Tool availability will update automatically.': diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 5d2cd05ec..dc4c1f8d9 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -43,7 +43,7 @@ import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; /** * Loads the core, hard-coded slash commands that are an integral part - * of the Gemini CLI application. + * of the Qwen Code application. */ export class BuiltinCommandLoader implements ICommandLoader { constructor(private config: Config | null) {} diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 5fa4baa03..8181583f9 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1229,7 +1229,7 @@ export const AppContainer = (props: AppContainerProps) => { useKeypress(handleGlobalKeypress, { isActive: true }); - // Update terminal title with Gemini CLI status and thoughts + // Update terminal title with Qwen Code status and thoughts useEffect(() => { // Respect both showStatusInTitle and hideWindowTitle settings if ( @@ -1256,7 +1256,7 @@ export const AppContainer = (props: AppContainerProps) => { lastTitleRef.current = paddedTitle; stdout.write(`\x1b]2;${paddedTitle}\x07`); } - // Note: We don't need to reset the window title on exit because Gemini CLI is already doing that elsewhere + // Note: We don't need to reset the window title on exit because Qwen Code is already doing that elsewhere }, [ streamingState, thought, diff --git a/packages/cli/src/ui/components/views/McpStatus.tsx b/packages/cli/src/ui/components/views/McpStatus.tsx index eac11b57a..0bf74db81 100644 --- a/packages/cli/src/ui/components/views/McpStatus.tsx +++ b/packages/cli/src/ui/components/views/McpStatus.tsx @@ -49,13 +49,6 @@ export const McpStatus: React.FC = ({ return ( {t('No MCP servers configured.')} - - {t('Please view MCP documentation in your browser:')}{' '} - - https://goo.gle/gemini-cli-docs-mcp - {' '} - {t('or use the cli /docs command')} - ); } diff --git a/packages/cli/src/utils/windowTitle.ts b/packages/cli/src/utils/windowTitle.ts index 58931af11..eab5eedd6 100644 --- a/packages/cli/src/utils/windowTitle.ts +++ b/packages/cli/src/utils/windowTitle.ts @@ -5,7 +5,7 @@ */ /** - * Computes the window title for the Gemini CLI application. + * Computes the window title for the Qwen Code application. * * @param folderName - The name of the current folder/workspace to display in the title * @returns The computed window title, either from CLI_TITLE environment variable or the default Gemini title diff --git a/packages/core/src/extension/gemini-converter.ts b/packages/core/src/extension/gemini-converter.ts index 8d793963e..7f5c2d054 100644 --- a/packages/core/src/extension/gemini-converter.ts +++ b/packages/core/src/extension/gemini-converter.ts @@ -5,7 +5,7 @@ */ /** - * Converter for Gemini CLI extensions to Qwen Code format. + * Converter for Gemini extensions to Qwen Code format. */ import * as fs from 'node:fs'; @@ -28,7 +28,7 @@ export interface GeminiExtensionConfig { } /** - * Converts a Gemini CLI extension config to Qwen Code format. + * Converts a Gemini extension config to Qwen Code format. * @param extensionDir Path to the Gemini extension directory * @returns Qwen ExtensionConfig */ diff --git a/packages/core/src/mcp/constants.ts b/packages/core/src/mcp/constants.ts index a352fc60b..ca9c27f3c 100644 --- a/packages/core/src/mcp/constants.ts +++ b/packages/core/src/mcp/constants.ts @@ -8,13 +8,13 @@ * OAuth client name used for MCP dynamic client registration. * This name must match the allowlist on MCP servers like Figma. */ -export const MCP_OAUTH_CLIENT_NAME = 'Gemini CLI MCP Client'; +export const MCP_OAUTH_CLIENT_NAME = 'Qwen Code MCP Client'; /** * OAuth client name for service account impersonation provider. */ export const MCP_SA_IMPERSONATION_CLIENT_NAME = - 'Gemini CLI (Service Account Impersonation)'; + 'Qwen Code (Service Account Impersonation)'; /** * Port for OAuth redirect callback server. diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index caabe5d92..2edf45860 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -1082,10 +1082,88 @@ describe('MCPOAuthProvider', () => { expect(capturedUrl!).toContain('code_challenge=code_challenge_mock'); expect(capturedUrl!).toContain('code_challenge_method=S256'); expect(capturedUrl!).toContain('scope=read+write'); + // resource should be the full canonical URI per MCP spec / RFC 8707 expect(capturedUrl!).toContain('resource=https%3A%2F%2Fauth.example.com'); expect(capturedUrl!).toContain('audience=https%3A%2F%2Fapi.example.com'); }); + // Regression test for https://github.com/QwenLM/qwen-code/issues/1749 + // Scenario: user runs `qwen mcp add --transport http yuque https://mcp.alibaba-inc.com/yuque/mcp` + // then `/mcp auth yuque`. Per MCP spec / RFC 8707, the resource param should be the + // full canonical URI "https://mcp.alibaba-inc.com/yuque/mcp", not just the host. + it('should use full canonical URI as resource parameter (issue #1749)', async () => { + let capturedAuthUrl: string | undefined; + mockOpenBrowserSecurely.mockImplementation((url: string) => { + capturedAuthUrl = url; + return Promise.resolve(); + }); + + let callbackHandler: unknown; + vi.mocked(http.createServer).mockImplementation((handler) => { + callbackHandler = handler; + return mockHttpServer as unknown as http.Server; + }); + + mockHttpServer.listen.mockImplementation((port, callback) => { + callback?.(); + setTimeout(() => { + const mockReq = { + url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw', + }; + const mockRes = { + writeHead: vi.fn(), + end: vi.fn(), + }; + (callbackHandler as (req: unknown, res: unknown) => void)( + mockReq, + mockRes, + ); + }, 10); + }); + + // Capture the token exchange request to verify resource param there too + let capturedTokenBody: string | undefined; + mockFetch.mockImplementation( + (url: string, options?: { body?: string }) => { + if (options?.body) { + capturedTokenBody = options.body; + } + return Promise.resolve( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockTokenResponse), + json: mockTokenResponse, + }), + ); + }, + ); + + const authProvider = new MCPOAuthProvider(); + + // Simulating what mcpCommand.ts does: + // serverName = "yuque" (the name the user gave) + // mcpServerUrl = "https://mcp.alibaba-inc.com/yuque/mcp" (server.httpUrl || server.url) + const serverName = 'yuque'; + const mcpServerUrl = 'https://mcp.alibaba-inc.com/yuque/mcp'; + + await authProvider.authenticate(serverName, mockConfig, mcpServerUrl); + + // Verify the authorization URL contains the full canonical URI as resource + expect(capturedAuthUrl).toBeDefined(); + const authUrl = new URL(capturedAuthUrl!); + const resourceInAuthUrl = authUrl.searchParams.get('resource'); + expect(resourceInAuthUrl).toBe('https://mcp.alibaba-inc.com/yuque/mcp'); + + // Verify the token exchange request also uses the full canonical URI + expect(capturedTokenBody).toBeDefined(); + const tokenParams = new URLSearchParams(capturedTokenBody!); + const resourceInTokenExchange = tokenParams.get('resource'); + expect(resourceInTokenExchange).toBe( + 'https://mcp.alibaba-inc.com/yuque/mcp', + ); + }); + it('should correctly append parameters to an authorization URL that already has query params', async () => { // Mock to capture the URL that would be opened let capturedUrl: string; diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 20723414e..1d1157c27 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -251,7 +251,7 @@ export class MCPOAuthProvider {

Authentication Successful!

-

You can close this window and return to Gemini CLI.

+

You can close this window and return to Qwen Code.

diff --git a/packages/core/src/mcp/oauth-utils.test.ts b/packages/core/src/mcp/oauth-utils.test.ts index 710afe21a..358213510 100644 --- a/packages/core/src/mcp/oauth-utils.test.ts +++ b/packages/core/src/mcp/oauth-utils.test.ts @@ -293,18 +293,53 @@ describe('OAuthUtils', () => { }); describe('buildResourceParameter', () => { - it('should build resource parameter from endpoint URL', () => { + it('should return canonical URI with full path', () => { const result = OAuthUtils.buildResourceParameter( 'https://example.com/oauth/token', ); - expect(result).toBe('https://example.com'); + expect(result).toBe('https://example.com/oauth/token'); }); it('should handle URLs with ports', () => { const result = OAuthUtils.buildResourceParameter( 'https://example.com:8080/oauth/token', ); - expect(result).toBe('https://example.com:8080'); + expect(result).toBe('https://example.com:8080/oauth/token'); + }); + + it('should strip query and fragment per RFC 8707', () => { + const result = OAuthUtils.buildResourceParameter( + 'https://example.com/mcp?foo=bar#frag', + ); + expect(result).toBe('https://example.com/mcp'); + }); + + it('should remove trailing slash from paths', () => { + expect( + OAuthUtils.buildResourceParameter('https://example.com/mcp/'), + ).toBe('https://example.com/mcp'); + }); + + it('should handle root URL consistently', () => { + // Both "https://example.com" and "https://example.com/" should + // produce the same canonical form without trailing slash + expect(OAuthUtils.buildResourceParameter('https://example.com')).toBe( + 'https://example.com', + ); + expect(OAuthUtils.buildResourceParameter('https://example.com/')).toBe( + 'https://example.com', + ); + }); + + // Regression test for https://github.com/QwenLM/qwen-code/issues/1749 + // Per MCP spec, resource should be the canonical URI including the path, + // so multi-tenant servers can distinguish between different MCP servers. + it('should preserve full path for multi-tenant MCP servers (issue #1749)', () => { + const result = OAuthUtils.buildResourceParameter( + 'https://mcp.alibaba-inc.com/yuque/mcp', + ); + // Must include the full path, not just the host + expect(result).toBe('https://mcp.alibaba-inc.com/yuque/mcp'); }); }); }); diff --git a/packages/core/src/mcp/oauth-utils.ts b/packages/core/src/mcp/oauth-utils.ts index dc65e0665..e5d5f3b74 100644 --- a/packages/core/src/mcp/oauth-utils.ts +++ b/packages/core/src/mcp/oauth-utils.ts @@ -355,11 +355,25 @@ export class OAuthUtils { /** * Build a resource parameter for OAuth requests. * - * @param endpointUrl The endpoint URL - * @returns The resource parameter value + * Per MCP spec and RFC 8707, the resource parameter MUST be the + * canonical URI of the MCP server. Clients SHOULD provide the most + * specific URI they can. The URI MUST NOT include a fragment and + * SHOULD NOT include a query component. + * + * @param endpointUrl The MCP server endpoint URL + * @returns The canonical resource URI */ static buildResourceParameter(endpointUrl: string): string { const url = new URL(endpointUrl); - return `${url.protocol}//${url.host}`; + // Build canonical URI: scheme + host + path (no query, no fragment) + // per RFC 8707 Section 2 and MCP spec Resource Parameter Implementation + const path = url.pathname === '/' ? '' : url.pathname; + let canonical = `${url.protocol}//${url.host}${path}`; + // Remove trailing slash from non-root paths for consistency + // (MCP spec recommends form without trailing slash) + if (canonical.endsWith('/') && path !== '') { + canonical = canonical.slice(0, -1); + } + return canonical; } } diff --git a/packages/core/src/mcp/token-storage/base-token-storage.test.ts b/packages/core/src/mcp/token-storage/base-token-storage.test.ts index 1e761d822..16462bbe7 100644 --- a/packages/core/src/mcp/token-storage/base-token-storage.test.ts +++ b/packages/core/src/mcp/token-storage/base-token-storage.test.ts @@ -53,7 +53,7 @@ describe('BaseTokenStorage', () => { let storage: TestTokenStorage; beforeEach(() => { - storage = new TestTokenStorage('gemini-cli-mcp-oauth'); + storage = new TestTokenStorage('qwen-code-mcp-oauth'); }); describe('validateCredentials', () => { diff --git a/packages/core/src/mcp/token-storage/hybrid-token-storage.test.ts b/packages/core/src/mcp/token-storage/hybrid-token-storage.test.ts index 5303d8477..8f1781ad6 100644 --- a/packages/core/src/mcp/token-storage/hybrid-token-storage.test.ts +++ b/packages/core/src/mcp/token-storage/hybrid-token-storage.test.ts @@ -101,8 +101,8 @@ describe('HybridTokenStorage', () => { expect(await storage.getStorageType()).toBe(TokenStorageType.KEYCHAIN); }); - it('should use file storage when GEMINI_FORCE_FILE_STORAGE is set', async () => { - process.env['GEMINI_FORCE_FILE_STORAGE'] = 'true'; + it('should use file storage when QWEN_CODE_FORCE_FILE_STORAGE is set', async () => { + process.env['QWEN_CODE_FORCE_FILE_STORAGE'] = 'true'; mockFileStorage.getCredentials.mockResolvedValue(null); await storage.getCredentials('test-server'); diff --git a/packages/core/src/mcp/token-storage/hybrid-token-storage.ts b/packages/core/src/mcp/token-storage/hybrid-token-storage.ts index 70edac07c..693285e20 100644 --- a/packages/core/src/mcp/token-storage/hybrid-token-storage.ts +++ b/packages/core/src/mcp/token-storage/hybrid-token-storage.ts @@ -9,7 +9,7 @@ import { FileTokenStorage } from './file-token-storage.js'; import type { TokenStorage, OAuthCredentials } from './types.js'; import { TokenStorageType } from './types.js'; -const FORCE_FILE_STORAGE_ENV_VAR = 'GEMINI_FORCE_FILE_STORAGE'; +const FORCE_FILE_STORAGE_ENV_VAR = 'QWEN_CODE_FORCE_FILE_STORAGE'; export class HybridTokenStorage extends BaseTokenStorage { private storage: TokenStorage | null = null; diff --git a/scripts/telemetry_gcp.js b/scripts/telemetry_gcp.js index 33ac2d42b..58c563eea 100755 --- a/scripts/telemetry_gcp.js +++ b/scripts/telemetry_gcp.js @@ -166,7 +166,7 @@ async function main() { console.log(`\n✨ Local OTEL collector for GCP is running.`); console.log( - '\n🚀 To send telemetry, run the Gemini CLI in a separate terminal window.', + '\n🚀 To send telemetry, run Qwen Code in a separate terminal window.', ); console.log(`\n📄 Collector logs are being written to: ${OTEL_LOG_FILE}`); console.log(