diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 187bd370..cd70da04 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -179,6 +179,28 @@ OAuth tokens are automatically: - **Validated** before each connection attempt - **Cleaned up** when invalid or expired +#### Authentication Provider Type + +You can specify the authentication provider type using the `authProviderType` property: + +- **`authProviderType`** (string): Specifies the authentication provider. Can be one of the following: + - **`dynamic_discovery`** (default): The CLI will automatically discover the OAuth configuration from the server. + - **`google_credentials`**: The CLI will use the Google Application Default Credentials (ADC) to authenticate with the server. When using this provider, you must specify the required scopes. + +```json +{ + "mcpServers": { + "googleCloudServer": { + "httpUrl": "https://my-gcp-service.run.app/mcp", + "authProviderType": "google_credentials", + "oauth": { + "scopes": ["https://www.googleapis.com/auth/userinfo.email"] + } + } + } +} +``` + ### Example Configurations #### Python MCP Server (Stdio) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4a9abfdc..231bbcd5 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -118,9 +118,15 @@ export class MCPServerConfig { readonly extensionName?: string, // OAuth configuration readonly oauth?: MCPOAuthConfig, + readonly authProviderType?: AuthProviderType, ) {} } +export enum AuthProviderType { + DYNAMIC_DISCOVERY = 'dynamic_discovery', + GOOGLE_CREDENTIALS = 'google_credentials', +} + export interface SandboxConfig { command: 'docker' | 'podman' | 'sandbox-exec'; image: string; diff --git a/packages/core/src/mcp/google-auth-provider.test.ts b/packages/core/src/mcp/google-auth-provider.test.ts new file mode 100644 index 00000000..f481b9e2 --- /dev/null +++ b/packages/core/src/mcp/google-auth-provider.test.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { GoogleAuth } from 'google-auth-library'; +import { GoogleCredentialProvider } from './google-auth-provider.js'; +import { vi, describe, beforeEach, it, expect, Mock } from 'vitest'; +import { MCPServerConfig } from '../config/config.js'; + +vi.mock('google-auth-library'); + +describe('GoogleCredentialProvider', () => { + it('should throw an error if no scopes are provided', () => { + expect(() => new GoogleCredentialProvider()).toThrow( + 'Scopes must be provided in the oauth config for Google Credentials provider', + ); + }); + + it('should use scopes from the config if provided', () => { + const config = { + oauth: { + scopes: ['scope1', 'scope2'], + }, + } as MCPServerConfig; + new GoogleCredentialProvider(config); + expect(GoogleAuth).toHaveBeenCalledWith({ + scopes: ['scope1', 'scope2'], + }); + }); + + describe('with provider instance', () => { + let provider: GoogleCredentialProvider; + + beforeEach(() => { + const config = { + oauth: { + scopes: ['scope1', 'scope2'], + }, + } as MCPServerConfig; + provider = new GoogleCredentialProvider(config); + vi.clearAllMocks(); + }); + + it('should return credentials', async () => { + const mockClient = { + getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }), + }; + (GoogleAuth.prototype.getClient as Mock).mockResolvedValue(mockClient); + + const credentials = await provider.tokens(); + + expect(credentials?.access_token).toBe('test-token'); + }); + + it('should return undefined if access token is not available', async () => { + const mockClient = { + getAccessToken: vi.fn().mockResolvedValue({ token: null }), + }; + (GoogleAuth.prototype.getClient as Mock).mockResolvedValue(mockClient); + + const credentials = await provider.tokens(); + expect(credentials).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/mcp/google-auth-provider.ts b/packages/core/src/mcp/google-auth-provider.ts new file mode 100644 index 00000000..88cd086b --- /dev/null +++ b/packages/core/src/mcp/google-auth-provider.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import { + OAuthClientInformation, + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import { GoogleAuth } from 'google-auth-library'; +import { MCPServerConfig } from '../config/config.js'; + +export class GoogleCredentialProvider implements OAuthClientProvider { + private readonly auth: GoogleAuth; + + // Properties required by OAuthClientProvider, with no-op values + readonly redirectUrl = ''; + readonly clientMetadata: OAuthClientMetadata = { + client_name: 'Gemini CLI (Google ADC)', + redirect_uris: [], + grant_types: [], + response_types: [], + token_endpoint_auth_method: 'none', + }; + private _clientInformation?: OAuthClientInformationFull; + + constructor(private readonly config?: MCPServerConfig) { + const scopes = this.config?.oauth?.scopes; + if (!scopes || scopes.length === 0) { + throw new Error( + 'Scopes must be provided in the oauth config for Google Credentials provider', + ); + } + this.auth = new GoogleAuth({ + scopes, + }); + } + + clientInformation(): OAuthClientInformation | undefined { + return this._clientInformation; + } + + saveClientInformation(clientInformation: OAuthClientInformationFull): void { + this._clientInformation = clientInformation; + } + + async tokens(): Promise { + const client = await this.auth.getClient(); + const accessTokenResponse = await client.getAccessToken(); + + if (!accessTokenResponse.token) { + console.error('Failed to get access token from Google ADC'); + return undefined; + } + + const tokens: OAuthTokens = { + access_token: accessTokenResponse.token, + token_type: 'Bearer', + }; + return tokens; + } + + saveTokens(_tokens: OAuthTokens): void { + // No-op, ADC manages tokens. + } + + redirectToAuthorization(_authorizationUrl: URL): void { + // No-op + } + + saveCodeVerifier(_codeVerifier: string): void { + // No-op + } + + codeVerifier(): string { + // No-op + return ''; + } +} diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index fbd2a2d4..428c9d2d 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -16,6 +16,8 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import * as SdkClientStdioLib from '@modelcontextprotocol/sdk/client/stdio.js'; import * as ClientLib from '@modelcontextprotocol/sdk/client/index.js'; import * as GenAiLib from '@google/genai'; +import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js'; +import { AuthProviderType } from '../config/config.js'; vi.mock('@modelcontextprotocol/sdk/client/stdio.js'); vi.mock('@modelcontextprotocol/sdk/client/index.js'); @@ -173,6 +175,63 @@ describe('mcp-client', () => { stderr: 'pipe', }); }); + + describe('useGoogleCredentialProvider', () => { + it('should use GoogleCredentialProvider when specified', async () => { + const transport = await createTransport( + 'test-server', + { + httpUrl: 'http://test-server', + authProviderType: AuthProviderType.GOOGLE_CREDENTIALS, + oauth: { + scopes: ['scope1'], + }, + }, + false, + ); + + expect(transport).toBeInstanceOf(StreamableHTTPClientTransport); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const authProvider = (transport as any)._authProvider; + expect(authProvider).toBeInstanceOf(GoogleCredentialProvider); + }); + + it('should use GoogleCredentialProvider with SSE transport', async () => { + const transport = await createTransport( + 'test-server', + { + url: 'http://test-server', + authProviderType: AuthProviderType.GOOGLE_CREDENTIALS, + oauth: { + scopes: ['scope1'], + }, + }, + false, + ); + + expect(transport).toBeInstanceOf(SSEClientTransport); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const authProvider = (transport as any)._authProvider; + expect(authProvider).toBeInstanceOf(GoogleCredentialProvider); + }); + + it('should throw an error if no URL is provided with GoogleCredentialProvider', async () => { + await expect( + createTransport( + 'test-server', + { + authProviderType: AuthProviderType.GOOGLE_CREDENTIALS, + oauth: { + scopes: ['scope1'], + }, + }, + false, + ), + ).rejects.toThrow( + 'No URL configured for Google Credentials MCP server', + ); + }); + }); }); describe('isEnabled', () => { const funcDecl = { name: 'myTool' }; diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 457259e5..3c482100 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -16,7 +16,8 @@ import { StreamableHTTPClientTransportOptions, } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { parse } from 'shell-quote'; -import { MCPServerConfig } from '../config/config.js'; +import { AuthProviderType, MCPServerConfig } from '../config/config.js'; +import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js'; import { DiscoveredMCPTool } from './mcp-tool.js'; import { FunctionDeclaration, mcpToTool } from '@google/genai'; import { ToolRegistry } from './tool-registry.js'; @@ -852,6 +853,29 @@ export async function createTransport( mcpServerConfig: MCPServerConfig, debugMode: boolean, ): Promise { + if ( + mcpServerConfig.authProviderType === AuthProviderType.GOOGLE_CREDENTIALS + ) { + const provider = new GoogleCredentialProvider(mcpServerConfig); + const transportOptions: + | StreamableHTTPClientTransportOptions + | SSEClientTransportOptions = { + authProvider: provider, + }; + if (mcpServerConfig.httpUrl) { + return new StreamableHTTPClientTransport( + new URL(mcpServerConfig.httpUrl), + transportOptions, + ); + } else if (mcpServerConfig.url) { + return new SSEClientTransport( + new URL(mcpServerConfig.url), + transportOptions, + ); + } + throw new Error('No URL configured for Google Credentials MCP server'); + } + // Check if we have OAuth configuration or stored tokens let accessToken: string | null = null; let hasOAuthConfig = mcpServerConfig.oauth?.enabled;