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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 431 additions and 4 deletions

View File

@ -95,6 +95,90 @@ Each server configuration supports the following properties:
- **`includeTools`** (string[]): List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (whitelist behavior). If not specified, all tools from the server are enabled by default.
- **`excludeTools`** (string[]): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server. **Note:** `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded.
### OAuth Support for Remote MCP Servers
The Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using SSE or HTTP transports. This enables secure access to MCP servers that require authentication.
#### Automatic OAuth Discovery
For servers that support OAuth discovery, you can omit the OAuth configuration and let the CLI discover it automatically:
```json
{
"mcpServers": {
"discoveredServer": {
"url": "https://api.example.com/sse"
}
}
}
```
The CLI will automatically:
- Detect when a server requires OAuth authentication (401 responses)
- Discover OAuth endpoints from server metadata
- Perform dynamic client registration if supported
- Handle the OAuth flow and token management
#### Authentication Flow
When connecting to an OAuth-enabled server:
1. **Initial connection attempt** fails with 401 Unauthorized
2. **OAuth discovery** finds authorization and token endpoints
3. **Browser opens** for user authentication (requires local browser access)
4. **Authorization code** is exchanged for access tokens
5. **Tokens are stored** securely for future use
6. **Connection retry** succeeds with valid tokens
#### Browser Redirect Requirements
**Important:** OAuth authentication requires that your local machine can:
- Open a web browser for authentication
- Receive redirects on `http://localhost:7777/oauth/callback`
This feature will not work in:
- Headless environments without browser access
- Remote SSH sessions without X11 forwarding
- Containerized environments without browser support
#### Managing OAuth Authentication
Use the `/mcp auth` command to manage OAuth authentication:
```bash
# List servers requiring authentication
/mcp auth
# Authenticate with a specific server
/mcp auth serverName
# Re-authenticate if tokens expire
/mcp auth serverName
```
#### OAuth Configuration Properties
- **`enabled`** (boolean): Enable OAuth for this server
- **`clientId`** (string): OAuth client identifier (optional with dynamic registration)
- **`clientSecret`** (string): OAuth client secret (optional for public clients)
- **`authorizationUrl`** (string): OAuth authorization endpoint (auto-discovered if omitted)
- **`tokenUrl`** (string): OAuth token endpoint (auto-discovered if omitted)
- **`scopes`** (string[]): Required OAuth scopes
- **`redirectUri`** (string): Custom redirect URI (defaults to `http://localhost:7777/oauth/callback`)
- **`tokenParamName`** (string): Query parameter name for tokens in SSE URLs
#### Token Management
OAuth tokens are automatically:
- **Stored securely** in `~/.gemini/mcp-oauth-tokens.json`
- **Refreshed** when expired (if refresh tokens are available)
- **Validated** before each connection attempt
- **Cleaned up** when invalid or expired
### Example Configurations
#### Python MCP Server (Stdio)

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