mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
MCP OAuth Part 3 - CLI/UI/Documentation (#4319)
Co-authored-by: Greg Shikhman <shikhman@google.com>
This commit is contained in:
@@ -30,6 +30,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...actual,
|
||||
getMCPServerStatus: vi.fn(),
|
||||
getMCPDiscoveryState: vi.fn(),
|
||||
MCPOAuthProvider: {
|
||||
authenticate: vi.fn(),
|
||||
},
|
||||
MCPOAuthTokenStorage: {
|
||||
getToken: vi.fn(),
|
||||
isTokenExpired: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -810,4 +817,163 @@ describe('mcpCommand', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth subcommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should list OAuth-enabled servers when no server name is provided', async () => {
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getMcpServers: vi.fn().mockReturnValue({
|
||||
'oauth-server': { oauth: { enabled: true } },
|
||||
'regular-server': {},
|
||||
'another-oauth': { oauth: { enabled: true } },
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const authCommand = mcpCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'auth',
|
||||
);
|
||||
expect(authCommand).toBeDefined();
|
||||
|
||||
const result = await authCommand!.action!(context, '');
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('oauth-server');
|
||||
expect(result.content).toContain('another-oauth');
|
||||
expect(result.content).not.toContain('regular-server');
|
||||
expect(result.content).toContain('/mcp auth <server-name>');
|
||||
}
|
||||
});
|
||||
|
||||
it('should show message when no OAuth servers are configured', async () => {
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getMcpServers: vi.fn().mockReturnValue({
|
||||
'regular-server': {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const authCommand = mcpCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'auth',
|
||||
);
|
||||
const result = await authCommand!.action!(context, '');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toBe(
|
||||
'No MCP servers configured with OAuth authentication.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should authenticate with a specific server', async () => {
|
||||
const mockToolRegistry = {
|
||||
discoverToolsForServer: vi.fn(),
|
||||
};
|
||||
const mockGeminiClient = {
|
||||
setTools: vi.fn(),
|
||||
};
|
||||
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getMcpServers: vi.fn().mockReturnValue({
|
||||
'test-server': {
|
||||
url: 'http://localhost:3000',
|
||||
oauth: { enabled: true },
|
||||
},
|
||||
}),
|
||||
getToolRegistry: vi.fn().mockResolvedValue(mockToolRegistry),
|
||||
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { MCPOAuthProvider } = await import('@google/gemini-cli-core');
|
||||
|
||||
const authCommand = mcpCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'auth',
|
||||
);
|
||||
const result = await authCommand!.action!(context, 'test-server');
|
||||
|
||||
expect(MCPOAuthProvider.authenticate).toHaveBeenCalledWith(
|
||||
'test-server',
|
||||
{ enabled: true },
|
||||
'http://localhost:3000',
|
||||
);
|
||||
expect(mockToolRegistry.discoverToolsForServer).toHaveBeenCalledWith(
|
||||
'test-server',
|
||||
);
|
||||
expect(mockGeminiClient.setTools).toHaveBeenCalled();
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('Successfully authenticated');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle authentication errors', async () => {
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getMcpServers: vi.fn().mockReturnValue({
|
||||
'test-server': { oauth: { enabled: true } },
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { MCPOAuthProvider } = await import('@google/gemini-cli-core');
|
||||
(
|
||||
MCPOAuthProvider.authenticate as ReturnType<typeof vi.fn>
|
||||
).mockRejectedValue(new Error('Auth failed'));
|
||||
|
||||
const authCommand = mcpCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'auth',
|
||||
);
|
||||
const result = await authCommand!.action!(context, 'test-server');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
expect(result.messageType).toBe('error');
|
||||
expect(result.content).toContain('Failed to authenticate');
|
||||
expect(result.content).toContain('Auth failed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle non-existent server', async () => {
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getMcpServers: vi.fn().mockReturnValue({
|
||||
'existing-server': {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const authCommand = mcpCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'auth',
|
||||
);
|
||||
const result = await authCommand!.action!(context, 'non-existent');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
expect(result.messageType).toBe('error');
|
||||
expect(result.content).toContain("MCP server 'non-existent' not found");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SlashCommandActionReturn,
|
||||
CommandContext,
|
||||
CommandKind,
|
||||
MessageActionReturn,
|
||||
} from './types.js';
|
||||
import {
|
||||
DiscoveredMCPTool,
|
||||
@@ -16,12 +17,16 @@ import {
|
||||
getMCPServerStatus,
|
||||
MCPDiscoveryState,
|
||||
MCPServerStatus,
|
||||
mcpServerRequiresOAuth,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import open from 'open';
|
||||
|
||||
const COLOR_GREEN = '\u001b[32m';
|
||||
const COLOR_YELLOW = '\u001b[33m';
|
||||
const COLOR_RED = '\u001b[31m';
|
||||
const COLOR_CYAN = '\u001b[36m';
|
||||
const COLOR_GREY = '\u001b[90m';
|
||||
const RESET_COLOR = '\u001b[0m';
|
||||
|
||||
const getMcpStatus = async (
|
||||
@@ -128,6 +133,31 @@ const getMcpStatus = async (
|
||||
// Format server header with bold formatting and status
|
||||
message += `${statusIndicator} \u001b[1m${serverDisplayName}\u001b[0m - ${statusText}`;
|
||||
|
||||
let needsAuthHint = mcpServerRequiresOAuth.get(serverName) || false;
|
||||
// Add OAuth status if applicable
|
||||
if (server?.oauth?.enabled) {
|
||||
needsAuthHint = true;
|
||||
try {
|
||||
const { MCPOAuthTokenStorage } = await import(
|
||||
'@google/gemini-cli-core'
|
||||
);
|
||||
const hasToken = await MCPOAuthTokenStorage.getToken(serverName);
|
||||
if (hasToken) {
|
||||
const isExpired = MCPOAuthTokenStorage.isTokenExpired(hasToken.token);
|
||||
if (isExpired) {
|
||||
message += ` ${COLOR_YELLOW}(OAuth token expired)${RESET_COLOR}`;
|
||||
} else {
|
||||
message += ` ${COLOR_GREEN}(OAuth authenticated)${RESET_COLOR}`;
|
||||
needsAuthHint = false;
|
||||
}
|
||||
} else {
|
||||
message += ` ${COLOR_RED}(OAuth not authenticated)${RESET_COLOR}`;
|
||||
}
|
||||
} catch (_err) {
|
||||
// If we can't check OAuth status, just continue
|
||||
}
|
||||
}
|
||||
|
||||
// Add tool count with conditional messaging
|
||||
if (status === MCPServerStatus.CONNECTED) {
|
||||
message += ` (${serverTools.length} tools)`;
|
||||
@@ -193,7 +223,11 @@ const getMcpStatus = async (
|
||||
}
|
||||
});
|
||||
} else {
|
||||
message += ' No tools available\n';
|
||||
message += ' No tools available';
|
||||
if (status === MCPServerStatus.DISCONNECTED && needsAuthHint) {
|
||||
message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}`;
|
||||
}
|
||||
message += '\n';
|
||||
}
|
||||
message += '\n';
|
||||
}
|
||||
@@ -213,6 +247,7 @@ const getMcpStatus = async (
|
||||
message += ` • Use ${COLOR_CYAN}/mcp desc${RESET_COLOR} to show server and tool descriptions\n`;
|
||||
message += ` • Use ${COLOR_CYAN}/mcp schema${RESET_COLOR} to show tool parameter schemas\n`;
|
||||
message += ` • Use ${COLOR_CYAN}/mcp nodesc${RESET_COLOR} to hide descriptions\n`;
|
||||
message += ` • Use ${COLOR_CYAN}/mcp auth <server-name>${RESET_COLOR} to authenticate with OAuth-enabled servers\n`;
|
||||
message += ` • Press ${COLOR_CYAN}Ctrl+T${RESET_COLOR} to toggle tool descriptions on/off\n`;
|
||||
message += '\n';
|
||||
}
|
||||
@@ -227,9 +262,139 @@ const getMcpStatus = async (
|
||||
};
|
||||
};
|
||||
|
||||
export const mcpCommand: SlashCommand = {
|
||||
name: 'mcp',
|
||||
description: 'list configured MCP servers and tools',
|
||||
const authCommand: SlashCommand = {
|
||||
name: 'auth',
|
||||
description: 'Authenticate with an OAuth-enabled MCP server',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
const serverName = args.trim();
|
||||
const { config } = context.services;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
};
|
||||
}
|
||||
|
||||
const mcpServers = config.getMcpServers() || {};
|
||||
|
||||
if (!serverName) {
|
||||
// List servers that support OAuth
|
||||
const oauthServers = Object.entries(mcpServers)
|
||||
.filter(([_, server]) => server.oauth?.enabled)
|
||||
.map(([name, _]) => name);
|
||||
|
||||
if (oauthServers.length === 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No MCP servers configured with OAuth authentication.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `MCP servers with OAuth authentication:\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\nUse /mcp auth <server-name> to authenticate.`,
|
||||
};
|
||||
}
|
||||
|
||||
const server = mcpServers[serverName];
|
||||
if (!server) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `MCP server '${serverName}' not found.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Always attempt OAuth authentication, even if not explicitly configured
|
||||
// The authentication process will discover OAuth requirements automatically
|
||||
|
||||
try {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: `Starting OAuth authentication for MCP server '${serverName}'...`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Import dynamically to avoid circular dependencies
|
||||
const { MCPOAuthProvider } = await import('@google/gemini-cli-core');
|
||||
|
||||
// Create OAuth config for authentication (will be discovered automatically)
|
||||
const oauthConfig = server.oauth || {
|
||||
authorizationUrl: '', // Will be discovered automatically
|
||||
tokenUrl: '', // Will be discovered automatically
|
||||
};
|
||||
|
||||
// Pass the MCP server URL for OAuth discovery
|
||||
const mcpServerUrl = server.httpUrl || server.url;
|
||||
await MCPOAuthProvider.authenticate(
|
||||
serverName,
|
||||
oauthConfig,
|
||||
mcpServerUrl,
|
||||
);
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: `✅ Successfully authenticated with MCP server '${serverName}'!`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Trigger tool re-discovery to pick up authenticated server
|
||||
const toolRegistry = await config.getToolRegistry();
|
||||
if (toolRegistry) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: `Re-discovering tools from '${serverName}'...`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
await toolRegistry.discoverToolsForServer(serverName);
|
||||
}
|
||||
// Update the client with the new tools
|
||||
const geminiClient = config.getGeminiClient();
|
||||
if (geminiClient) {
|
||||
await geminiClient.setTools();
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Successfully authenticated and refreshed tools for '${serverName}'.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
completion: async (context: CommandContext, partialArg: string) => {
|
||||
const { config } = context.services;
|
||||
if (!config) return [];
|
||||
|
||||
const mcpServers = config.getMcpServers() || {};
|
||||
return Object.keys(mcpServers).filter((name) =>
|
||||
name.startsWith(partialArg),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const listCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
description: 'List configured MCP servers and tools',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext, args: string) => {
|
||||
const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
@@ -251,3 +416,15 @@ export const mcpCommand: SlashCommand = {
|
||||
return getMcpStatus(context, showDescriptions, showSchema, showTips);
|
||||
},
|
||||
};
|
||||
|
||||
export const mcpCommand: SlashCommand = {
|
||||
name: 'mcp',
|
||||
description:
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [listCommand, authCommand],
|
||||
// Default action when no subcommand is provided
|
||||
action: async (context: CommandContext, args: string) =>
|
||||
// If no subcommand, run the list command
|
||||
listCommand.action!(context, args),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user