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:
tanzhenxin 2026-02-08 10:46:48 +08:00
parent 36931e1eab
commit 21e711469d
27 changed files with 244 additions and 124 deletions

View file

@ -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

View file

@ -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({

View file

@ -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', {

View file

@ -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';

View file

@ -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.',
);
});
});

View file

@ -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) => {

View file

@ -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

View file

@ -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.':

View file

@ -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.':

View file

@ -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.':

View file

@ -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.':

View file

@ -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.':

View file

@ -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.':

View file

@ -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) {}

View file

@ -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,

View file

@ -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>
);
}

View file

@ -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

View file

@ -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
*/

View file

@ -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.

View file

@ -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;

View file

@ -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>

View file

@ -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');
});
});
});

View file

@ -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;
}
}

View file

@ -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', () => {

View file

@ -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');

View file

@ -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;

View file

@ -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(