mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
fix(mcp): update OAuth client names and improve MCP commands
- Rename MCP OAuth client names from 'Gemini CLI' to 'Qwen Code' - Update MCP add/remove/list commands with improved error handling - Add comprehensive tests for OAuth provider - Fix token storage test assertions - Clean up unused i18n translation keys - Update gemini-converter and window title references Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
36931e1eab
commit
21e711469d
27 changed files with 244 additions and 124 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<typeof import('@qwen-code/qwen-code-core')>();
|
||||
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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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] <name>')
|
||||
.usage('Usage: qwen mcp remove [options] <name>')
|
||||
.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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -49,13 +49,6 @@ export const McpStatus: React.FC<McpStatusProps> = ({
|
|||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>{t('No MCP servers configured.')}</Text>
|
||||
<Text>
|
||||
{t('Please view MCP documentation in your browser:')}{' '}
|
||||
<Text color={theme.text.link}>
|
||||
https://goo.gle/gemini-cli-docs-mcp
|
||||
</Text>{' '}
|
||||
{t('or use the cli /docs command')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ export class MCPOAuthProvider {
|
|||
<html>
|
||||
<body>
|
||||
<h1>Authentication Successful!</h1>
|
||||
<p>You can close this window and return to Gemini CLI.</p>
|
||||
<p>You can close this window and return to Qwen Code.</p>
|
||||
<script>window.close();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue