Path: blob/main/extensions/copilot/src/extension/githubMcp/test/node/githubMcpDefinitionProvider.spec.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { beforeEach, describe, expect, test } from 'vitest';6import type { AuthenticationGetSessionOptions, AuthenticationSession } from 'vscode';7import { BaseAuthenticationService, IAuthenticationService, StrictAuthenticationPresentationOptions } from '../../../../platform/authentication/common/authentication';8import { CopilotToken } from '../../../../platform/authentication/common/copilotToken';9import { ICopilotTokenManager } from '../../../../platform/authentication/common/copilotTokenManager';10import { CopilotTokenStore, ICopilotTokenStore } from '../../../../platform/authentication/common/copilotTokenStore';11import { SimulationTestCopilotTokenManager } from '../../../../platform/authentication/test/node/simulationTestCopilotTokenManager';12import { AuthProviderId, ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';13import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';14import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';15import { ILogService, LogServiceImpl } from '../../../../platform/log/common/logService';16import { TestingServiceCollection } from '../../../../platform/test/node/services';17import { raceTimeout } from '../../../../util/vs/base/common/async';18import { CancellationToken } from '../../../../util/vs/base/common/cancellation';19import { Emitter, Event } from '../../../../util/vs/base/common/event';20import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors';21import { GitHubMcpDefinitionProvider } from '../../common/githubMcpDefinitionProvider';2223/**24* Test implementation of authentication service that allows setting sessions dynamically25*/26class TestAuthenticationService extends BaseAuthenticationService {27private readonly _onDidChange = new Emitter<void>();2829constructor(30@ILogService logService: ILogService,31@ICopilotTokenStore tokenStore: ICopilotTokenStore,32@ICopilotTokenManager tokenManager: ICopilotTokenManager,33@IConfigurationService configurationService: IConfigurationService34) {35super(logService, tokenStore, tokenManager, configurationService);36this._register(this._onDidChange);37}3839setPermissiveGitHubSession(session: AuthenticationSession | undefined): void {40this._permissiveGitHubSession = session;41this.fireAuthenticationChange('setPermissiveGitHubSession');42}4344override getGitHubSession(kind: 'permissive' | 'any', options: AuthenticationGetSessionOptions & { createIfNone: StrictAuthenticationPresentationOptions }): Promise<AuthenticationSession>;45override getGitHubSession(kind: 'permissive' | 'any', options: AuthenticationGetSessionOptions & { forceNewSession: StrictAuthenticationPresentationOptions }): Promise<AuthenticationSession>;46override getGitHubSession(kind: 'permissive' | 'any', options?: AuthenticationGetSessionOptions): Promise<AuthenticationSession | undefined> {47if (kind === 'permissive') {48if (options?.createIfNone && !this._permissiveGitHubSession) {49throw new Error('No permissive GitHub session available');50}51return Promise.resolve(this._permissiveGitHubSession);52} else {53return Promise.resolve(this._anyGitHubSession);54}55}5657override getAnyAdoSession(_options?: AuthenticationGetSessionOptions): Promise<AuthenticationSession | undefined> {58return Promise.resolve(undefined);59}6061override getAdoAccessTokenBase64(_options?: AuthenticationGetSessionOptions): Promise<string | undefined> {62return Promise.resolve(undefined);63}6465override async getCopilotToken(_force?: boolean): Promise<CopilotToken> {66return await super.getCopilotToken(_force);67}68}6970describe('GitHubMcpDefinitionProvider', () => {71let configService: InMemoryConfigurationService;72let authService: TestAuthenticationService;73let provider: GitHubMcpDefinitionProvider;7475/**76* Helper to create a provider with specific configuration values.77*/78async function createProvider(configOverrides?: {79authProvider?: AuthProviderId;80gheUri?: string;81toolsets?: string[];82readonly?: boolean;83lockdown?: boolean;84channel?: ConfigKey.GitHubMcpChannelValue;85hasPermissiveToken?: boolean;86}): Promise<GitHubMcpDefinitionProvider> {87const serviceCollection = new TestingServiceCollection();88configService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());8990// Set configuration values before creating the provider91if (configOverrides?.authProvider) {92await configService.setConfig(ConfigKey.Shared.AuthProvider, configOverrides.authProvider);93}94if (configOverrides?.gheUri) {95await configService.setNonExtensionConfig('github-enterprise.uri', configOverrides.gheUri);96}97if (configOverrides?.toolsets) {98await configService.setConfig(ConfigKey.GitHubMcpToolsets, configOverrides.toolsets);99}100if (configOverrides?.readonly !== undefined) {101await configService.setConfig(ConfigKey.GitHubMcpReadonly, configOverrides.readonly);102}103if (configOverrides?.lockdown !== undefined) {104await configService.setConfig(ConfigKey.GitHubMcpLockdown, configOverrides.lockdown);105}106if (configOverrides?.channel !== undefined) {107await configService.setConfig(ConfigKey.GitHubMcpChannel, configOverrides.channel);108}109110serviceCollection.define(IConfigurationService, configService);111serviceCollection.define(ICopilotTokenStore, new SyncDescriptor(CopilotTokenStore));112serviceCollection.define(ICopilotTokenManager, new SyncDescriptor(SimulationTestCopilotTokenManager));113serviceCollection.define(IAuthenticationService, new SyncDescriptor(TestAuthenticationService));114serviceCollection.define(ILogService, new LogServiceImpl([]));115const accessor = serviceCollection.createTestingAccessor();116117// Get the auth service and set up permissive token if needed118authService = accessor.get(IAuthenticationService) as TestAuthenticationService;119if (configOverrides?.hasPermissiveToken !== false) {120authService.setPermissiveGitHubSession({ accessToken: 'test-token', id: 'test-id', account: { id: 'test-account', label: 'test' }, scopes: [] });121}122123return new GitHubMcpDefinitionProvider(124accessor.get(IConfigurationService),125accessor.get(IAuthenticationService),126accessor.get(ILogService)127);128}129130beforeEach(async () => {131provider = await createProvider();132});133134describe('provideMcpServerDefinitions', () => {135test('returns GitHub.com configuration by default', () => {136const definitions = provider.provideMcpServerDefinitions();137138expect(definitions).toHaveLength(1);139expect(definitions[0].label).toBe('GitHub');140expect(definitions[0].uri.toString()).toBe('https://api.githubcopilot.com/mcp/');141});142143test('returns GitHub Enterprise configuration when auth provider is set to GHE', async () => {144const gheUri = 'https://github.enterprise.com';145const gheProvider = await createProvider({146authProvider: AuthProviderId.GitHubEnterprise,147gheUri148});149150const definitions = gheProvider.provideMcpServerDefinitions();151152expect(definitions).toHaveLength(1);153expect(definitions[0].label).toBe('GitHub Enterprise');154// Should include the copilot-api. prefix155expect(definitions[0].uri.toString()).toBe('https://copilot-api.github.enterprise.com/mcp/');156});157158test('includes configured toolsets in headers', async () => {159const toolsets = ['code_search', 'issues', 'pull_requests'];160const providerWithToolsets = await createProvider({ toolsets });161162const definitions = providerWithToolsets.provideMcpServerDefinitions();163164expect(definitions[0].headers['X-MCP-Toolsets']).toBe('code_search,issues,pull_requests');165});166167test('handles empty toolsets configuration', async () => {168const providerWithEmptyToolsets = await createProvider({ toolsets: [] });169170const definitions = providerWithEmptyToolsets.provideMcpServerDefinitions();171172expect(definitions[0].headers['X-MCP-Toolsets']).toBeUndefined();173});174175test('version is the sorted toolset string', async () => {176const toolsets = ['pull_requests', 'code_search', 'issues'];177const providerWithToolsets = await createProvider({ toolsets });178const definitions = providerWithToolsets.provideMcpServerDefinitions();179// Sorted toolsets string180expect(definitions[0].version).toBe('code_search,issues,pull_requests');181});182183test('throws when GHE is configured but URI is missing', async () => {184const gheProviderWithoutUri = await createProvider({185authProvider: AuthProviderId.GitHubEnterprise186// Don't set the GHE URI187});188189expect(() => gheProviderWithoutUri.provideMcpServerDefinitions()).toThrow('GitHub Enterprise URI is not configured.');190});191192test('includes X-MCP-Readonly header when readonly is true', async () => {193const readonlyProvider = await createProvider({ readonly: true });194195const definitions = readonlyProvider.provideMcpServerDefinitions();196197expect(definitions[0].headers['X-MCP-Readonly']).toBe('true');198});199200test('does not include X-MCP-Readonly header when readonly is false', async () => {201const nonReadonlyProvider = await createProvider({ readonly: false });202203const definitions = nonReadonlyProvider.provideMcpServerDefinitions();204205expect(definitions[0].headers['X-MCP-Readonly']).toBeUndefined();206});207208test('includes X-MCP-Lockdown header when lockdown is true', async () => {209const lockdownProvider = await createProvider({ lockdown: true });210211const definitions = lockdownProvider.provideMcpServerDefinitions();212213expect(definitions[0].headers['X-MCP-Lockdown']).toBe('true');214});215216test('does not include X-MCP-Lockdown header when lockdown is false', async () => {217const nonLockdownProvider = await createProvider({ lockdown: false });218219const definitions = nonLockdownProvider.provideMcpServerDefinitions();220221expect(definitions[0].headers['X-MCP-Lockdown']).toBeUndefined();222});223224test('includes both readonly and lockdown headers when both are true', async () => {225const bothProvider = await createProvider({ readonly: true, lockdown: true });226227const definitions = bothProvider.provideMcpServerDefinitions();228229expect(definitions[0].headers['X-MCP-Readonly']).toBe('true');230expect(definitions[0].headers['X-MCP-Lockdown']).toBe('true');231});232233test('version includes readonly flag when readonly is true', async () => {234const readonlyProvider = await createProvider({ readonly: true });235236const definitions = readonlyProvider.provideMcpServerDefinitions();237238expect(definitions[0].version).toBe('default|readonly');239});240241test('version includes lockdown flag when lockdown is true', async () => {242const lockdownProvider = await createProvider({ lockdown: true });243244const definitions = lockdownProvider.provideMcpServerDefinitions();245246expect(definitions[0].version).toBe('default|lockdown');247});248249test('version includes both flags when both readonly and lockdown are true', async () => {250const bothProvider = await createProvider({ readonly: true, lockdown: true });251252const definitions = bothProvider.provideMcpServerDefinitions();253254expect(definitions[0].version).toBe('default|readonly|lockdown');255});256257test('includes X-MCP-Insiders header when channel is insiders', async () => {258const insidersProvider = await createProvider({ channel: 'insiders' });259260const definitions = insidersProvider.provideMcpServerDefinitions();261262expect(definitions[0].headers['X-MCP-Insiders']).toBe('true');263});264265test('does not include X-MCP-Insiders header when channel is stable', async () => {266const stableProvider = await createProvider({ channel: 'stable' });267268const definitions = stableProvider.provideMcpServerDefinitions();269270expect(definitions[0].headers['X-MCP-Insiders']).toBeUndefined();271});272273test('version includes insiders flag when channel is insiders', async () => {274const insidersProvider = await createProvider({ channel: 'insiders' });275276const definitions = insidersProvider.provideMcpServerDefinitions();277278expect(definitions[0].version).toBe('default|insiders');279});280281test('version includes all flags when readonly, lockdown, and insiders are set', async () => {282const allFlagsProvider = await createProvider({ readonly: true, lockdown: true, channel: 'insiders' });283284const definitions = allFlagsProvider.provideMcpServerDefinitions();285286expect(definitions[0].version).toBe('default|readonly|lockdown|insiders');287});288289test('version is just toolsets when readonly and lockdown are false', async () => {290const toolsets = ['issues', 'pull_requests'];291const normalProvider = await createProvider({ toolsets, readonly: false, lockdown: false });292293const definitions = normalProvider.provideMcpServerDefinitions();294295expect(definitions[0].version).toBe('issues,pull_requests');296});297298test('version with empty toolsets and readonly', async () => {299const readonlyEmptyProvider = await createProvider({ toolsets: [], readonly: true });300301const definitions = readonlyEmptyProvider.provideMcpServerDefinitions();302303expect(definitions[0].version).toBe('0|readonly');304});305});306307describe('onDidChangeMcpServerDefinitions', () => {308test('fires when toolsets configuration changes', async () => {309const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions);310311await configService.setConfig(ConfigKey.GitHubMcpToolsets, ['new_toolset']);312313await eventPromise;314});315316test('fires when auth provider configuration changes', async () => {317const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions);318319await configService.setConfig(ConfigKey.Shared.AuthProvider, AuthProviderId.GitHubEnterprise);320321await eventPromise;322});323324test('fires when GHE URI configuration changes', async () => {325await configService.setConfig(ConfigKey.Shared.AuthProvider, AuthProviderId.GitHubEnterprise);326await configService.setNonExtensionConfig('github-enterprise.uri', 'https://old.enterprise.com');327328const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions);329330await configService.setNonExtensionConfig('github-enterprise.uri', 'https://new.enterprise.com');331332await eventPromise;333});334335test('does not fire for unrelated configuration changes', async () => {336let eventFired = false;337const handler = () => {338eventFired = true;339};340const disposable = provider.onDidChangeMcpServerDefinitions(handler);341342await configService.setNonExtensionConfig('some.unrelated.config', 'value');343344await raceTimeout(Promise.resolve(), 50);345346expect(eventFired).toBe(false);347disposable.dispose();348});349350test('fires when readonly configuration changes', async () => {351const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions);352353await configService.setConfig(ConfigKey.GitHubMcpReadonly, true);354355await eventPromise;356});357358test('fires when lockdown configuration changes', async () => {359const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions);360361await configService.setConfig(ConfigKey.GitHubMcpLockdown, true);362363await eventPromise;364});365366test('fires when channel configuration changes', async () => {367const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions);368369await configService.setConfig(ConfigKey.GitHubMcpChannel, 'insiders');370371await eventPromise;372});373});374375describe('edge cases', () => {376test('uses default toolsets value when not configured', () => {377const definitions = provider.provideMcpServerDefinitions();378expect(definitions).toHaveLength(1);379expect(definitions[0].headers['X-MCP-Toolsets']).toBe('default');380expect(definitions[0].version).toBe('default');381});382});383384describe('resolveMcpServerDefinition', () => {385test('adds authorization header when permissive token is available', async () => {386const definitions = provider.provideMcpServerDefinitions();387const resolved = await provider.resolveMcpServerDefinition(definitions[0], CancellationToken.None);388389expect(resolved).toBeDefined();390expect(resolved.headers['Authorization']).toBe('Bearer test-token');391});392393test('throws when no permissive token is available and session cannot be created', async () => {394const providerWithoutToken = await createProvider({ hasPermissiveToken: false });395const definitions = providerWithoutToken.provideMcpServerDefinitions();396397// Since the mock returns undefined and the implementation uses session!.accessToken,398// this will throw when trying to access accessToken on undefined399await expect(providerWithoutToken.resolveMcpServerDefinition(definitions[0], CancellationToken.None)).rejects.toThrow();400});401});402403describe('authentication change events', () => {404test('fires onDidChangeMcpServerDefinitions when token becomes available', async () => {405const providerWithoutToken = await createProvider({ hasPermissiveToken: false });406const eventPromise = Event.toPromise(providerWithoutToken.onDidChangeMcpServerDefinitions);407408authService.setPermissiveGitHubSession({ accessToken: 'new-token', id: 'new-id', account: { id: 'new-account', label: 'new' }, scopes: [] });409410await eventPromise;411});412413test('fires onDidChangeMcpServerDefinitions when token is removed', async () => {414const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions);415416authService.setPermissiveGitHubSession(undefined);417418await eventPromise;419});420421test('does not fire when token changes but availability remains the same', async () => {422let eventFired = false;423const handler = () => {424eventFired = true;425};426const disposable = provider.onDidChangeMcpServerDefinitions(handler);427428// Change the token value but keep it defined429authService.setPermissiveGitHubSession({ accessToken: 'different-token', id: 'different-id', account: { id: 'different-account', label: 'different' }, scopes: [] });430431await raceTimeout(Promise.resolve(), 50);432433expect(eventFired).toBe(false);434disposable.dispose();435});436});437});438439440