summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/ui/commands/mcpCommand.test.ts166
-rw-r--r--packages/cli/src/ui/commands/mcpCommand.ts185
2 files changed, 347 insertions, 4 deletions
diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts
index f23cf3ab..e52cb9df 100644
--- a/packages/cli/src/ui/commands/mcpCommand.test.ts
+++ b/packages/cli/src/ui/commands/mcpCommand.test.ts
@@ -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");
+ }
+ });
+ });
});
diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts
index 373f1ca5..c33a25d1 100644
--- a/packages/cli/src/ui/commands/mcpCommand.ts
+++ b/packages/cli/src/ui/commands/mcpCommand.ts
@@ -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),
+};