MCP OAuth Part 3 - CLI/UI/Documentation (#4319)

Co-authored-by: Greg Shikhman <shikhman@google.com>
This commit is contained in:
Brian Ray
2025-07-22 10:05:36 -04:00
committed by GitHub
parent 258c848909
commit 4d653c833a
3 changed files with 431 additions and 4 deletions

View File

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

View File

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