diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 466c85534cd..390db8c2c12 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1061,6 +1061,7 @@ "modelDescription": "Searches a GitHub repository for relevant source code snippets. Only use this tool if the user is very clearly asking for code snippets from a specific GitHub repository. Do not use this tool for Github repos that the user has open in their workspace.", "userDescription": "%github.copilot.tools.githubRepo.userDescription%", "icon": "$(repo)", + "when": "!config.github.copilot.chat.githubMcpServer.enabled", "inputSchema": { "type": "object", "properties": { @@ -1841,6 +1842,12 @@ "when": "!github.copilot.interactiveSession.disabled" } ], + "mcpServerDefinitionProviders": [ + { + "id": "github", + "label": "GitHub" + } + ], "viewsWelcome": [ { "view": "debug", @@ -2745,6 +2752,41 @@ { "id": "experimental", "properties": { + "github.copilot.chat.githubMcpServer.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%github.copilot.config.githubMcpServer.enabled%", + "tags": [ + "experimental" + ] + }, + "github.copilot.chat.githubMcpServer.toolsets": { + "type": "array", + "default": ["default"], + "markdownDescription": "%github.copilot.config.githubMcpServer.toolsets%", + "items": { + "type": "string" + }, + "tags": [ + "experimental" + ] + }, + "github.copilot.chat.githubMcpServer.readonly": { + "type": "boolean", + "default": false, + "markdownDescription": "%github.copilot.config.githubMcpServer.readonly%", + "tags": [ + "experimental" + ] + }, + "github.copilot.chat.githubMcpServer.lockdown": { + "type": "boolean", + "default": false, + "markdownDescription": "%github.copilot.config.githubMcpServer.lockdown%", + "tags": [ + "experimental" + ] + }, "github.copilot.chat.imageUpload.enabled": { "type": "boolean", "default": true, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 00aa3b1517c..b1efce0cb8d 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -416,5 +416,9 @@ "github.copilot.cli.sessions.newTerminalSession": "New Agent Session in Terminal", "github.copilot.command.openCopilotAgentSessionsInBrowser": "Open in Browser", "github.copilot.command.closeChatSessionPullRequest.title": "Close Pull Request", - "github.copilot.command.applyCopilotCLIAgentSessionChanges": "Apply Changes" + "github.copilot.command.applyCopilotCLIAgentSessionChanges": "Apply Changes", + "github.copilot.config.githubMcpServer.enabled": "Enable built-in support for the GitHub MCP Server.", + "github.copilot.config.githubMcpServer.toolsets": "Specify toolsets to use from the GitHub MCP Server.", + "github.copilot.config.githubMcpServer.readonly": "Enable read-only mode for the GitHub MCP Server. When enabled, only read tools are available.", + "github.copilot.config.githubMcpServer.lockdown": "Enable lockdown mode for the GitHub MCP Server. When enabled, hides public issue details created by users without push access." } diff --git a/extensions/copilot/src/extension/extension/vscode-node/contributions.ts b/extensions/copilot/src/extension/extension/vscode-node/contributions.ts index 81826ebe4b4..ddf54ceb0a8 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/contributions.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/contributions.ts @@ -24,6 +24,7 @@ import { DiagnosticsContextContribution } from '../../diagnosticsContext/vscode/ import { LanguageModelProxyContrib } from '../../externalAgents/vscode-node/lmProxyContrib'; import { WalkthroughCommandContribution } from '../../getting-started/vscode-node/commands'; import * as newWorkspaceContribution from '../../getting-started/vscode-node/newWorkspace.contribution'; +import { GitHubMcpContrib } from '../../githubMcp/vscode-node/githubMcp.contribution'; import { IgnoredFileProviderContribution } from '../../ignore/vscode-node/ignoreProvider'; import { InlineEditProviderFeature } from '../../inlineEdits/vscode-node/inlineEditProviderFeature'; import { FixTestFailureContribution } from '../../intents/vscode-node/fixTestFailureContributions'; @@ -86,7 +87,8 @@ export const vscodeNodeContributions: IExtensionContributionFactory[] = [ asContributionFactory(CompletionsCoreContribution), asContributionFactory(CompletionsUnificationContribution), workspaceIndexingContribution, - asContributionFactory(ChatSessionsContrib) + asContributionFactory(ChatSessionsContrib), + asContributionFactory(GitHubMcpContrib) ]; /** diff --git a/extensions/copilot/src/extension/githubMcp/common/githubMcpDefinitionProvider.ts b/extensions/copilot/src/extension/githubMcp/common/githubMcpDefinitionProvider.ts new file mode 100644 index 00000000000..8d95e407269 --- /dev/null +++ b/extensions/copilot/src/extension/githubMcp/common/githubMcpDefinitionProvider.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import type { CancellationToken, McpHttpServerDefinition, McpServerDefinitionProvider } from 'vscode'; +import { authProviderId, IAuthenticationService } from '../../../platform/authentication/common/authentication'; +import { AuthProviderId, ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { ILogService } from '../../../platform/log/common/logService'; +import { Event } from '../../../util/vs/base/common/event'; +import { URI } from '../../../util/vs/base/common/uri'; + +const EnterpriseURLConfig = 'github-enterprise.uri'; + +export class GitHubMcpDefinitionProvider implements McpServerDefinitionProvider { + + readonly onDidChangeMcpServerDefinitions: Event; + + constructor( + @IConfigurationService private readonly configurationService: IConfigurationService, + @IAuthenticationService private readonly authenticationService: IAuthenticationService, + @ILogService private readonly logService: ILogService + ) { + const configurationEvent = Event.chain(configurationService.onDidChangeConfiguration, $ => $ + .filter(e => { + // If they change the toolsets + if (e.affectsConfiguration(ConfigKey.GitHubMcpToolsets.fullyQualifiedId)) { + logService.debug('GitHubMcpDefinitionProvider: Configuration change affects GitHub MCP toolsets.'); + return true; + } + // If they change readonly mode + if (e.affectsConfiguration(ConfigKey.GitHubMcpReadonly.fullyQualifiedId)) { + logService.debug('GitHubMcpDefinitionProvider: Configuration change affects GitHub MCP readonly mode.'); + return true; + } + // If they change lockdown mode + if (e.affectsConfiguration(ConfigKey.GitHubMcpLockdown.fullyQualifiedId)) { + logService.debug('GitHubMcpDefinitionProvider: Configuration change affects GitHub MCP lockdown mode.'); + return true; + } + // If they change to GHE or GitHub.com + if (e.affectsConfiguration(ConfigKey.Shared.AuthProvider.fullyQualifiedId)) { + logService.debug('GitHubMcpDefinitionProvider: Configuration change affects GitHub auth provider.'); + return true; + } + // If they change the GHE URL + if (e.affectsConfiguration(EnterpriseURLConfig)) { + logService.debug('GitHubMcpDefinitionProvider: Configuration change affects GitHub Enterprise URL.'); + return true; + } + return false; + }) + // void event + .map(() => { }) + ); + let havePermissiveToken = !!this.authenticationService.permissiveGitHubSession; + const authEvent = Event.chain(this.authenticationService.onDidAuthenticationChange, $ => $ + .filter(() => { + const hadToken = havePermissiveToken; + havePermissiveToken = !!this.authenticationService.permissiveGitHubSession; + return hadToken !== havePermissiveToken; + }) + .map(() => { + this.logService.debug(`GitHubMcpDefinitionProvider: Permissive GitHub session availability changed: ${havePermissiveToken}`); + }) + ); + this.onDidChangeMcpServerDefinitions = Event.any(configurationEvent, authEvent); + } + + private get toolsets(): string[] { + return this.configurationService.getConfig(ConfigKey.GitHubMcpToolsets); + } + + private get readonly(): boolean { + return this.configurationService.getConfig(ConfigKey.GitHubMcpReadonly); + } + + private get lockdown(): boolean { + return this.configurationService.getConfig(ConfigKey.GitHubMcpLockdown); + } + + private get gheConfig(): string | undefined { + return this.configurationService.getNonExtensionConfig(EnterpriseURLConfig); + } + + private getGheUri(): URI { + const uri = this.gheConfig; + if (!uri) { + throw new Error('GitHub Enterprise URI is not configured.'); + } + // Prefix with 'copilot-api.' + const url = URI.parse(uri).with({ path: '/mcp/' }); + return url.with({ authority: `copilot-api.${url.authority}` }); + } + + provideMcpServerDefinitions(): McpHttpServerDefinition[] { + const providerId = authProviderId(this.configurationService); + const toolsets = this.toolsets.sort().join(','); + const readonly = this.readonly; + const lockdown = this.lockdown; + + const basics = providerId === AuthProviderId.GitHubEnterprise + ? { label: 'GitHub Enterprise', uri: this.getGheUri() } + : { label: 'GitHub', uri: URI.parse('https://api.githubcopilot.com/mcp/') }; + + // Build headers object conditionally + const headers: Record = {}; + // Build version string with toolsets and flags + let version = toolsets.length ? toolsets : '0'; + if (toolsets.length > 0) { + headers['X-MCP-Toolsets'] = toolsets; + } + if (readonly) { + headers['X-MCP-Readonly'] = 'true'; + version += '|readonly'; + } + if (lockdown) { + headers['X-MCP-Lockdown'] = 'true'; + version += '|lockdown'; + } + return [ + { + ...basics, + headers, + version + } + ]; + } + + async resolveMcpServerDefinition(server: McpHttpServerDefinition, token: CancellationToken): Promise { + const session = await this.authenticationService.getPermissiveGitHubSession({ + createIfNone: { + detail: l10n.t('Additional permissions are required to use GitHub MCP Server'), + }, + }); + if (!session) { + throw new Error('Authentication required'); + } + server.headers['Authorization'] = `Bearer ${session.accessToken}`; + return server; + } +} diff --git a/extensions/copilot/src/extension/githubMcp/test/node/githubMcpDefinitionProvider.spec.ts b/extensions/copilot/src/extension/githubMcp/test/node/githubMcpDefinitionProvider.spec.ts new file mode 100644 index 00000000000..bfb4dcb83bf --- /dev/null +++ b/extensions/copilot/src/extension/githubMcp/test/node/githubMcpDefinitionProvider.spec.ts @@ -0,0 +1,392 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, test } from 'vitest'; +import type { AuthenticationGetSessionOptions, AuthenticationSession } from 'vscode'; +import { BaseAuthenticationService, IAuthenticationService } from '../../../../platform/authentication/common/authentication'; +import { CopilotToken } from '../../../../platform/authentication/common/copilotToken'; +import { ICopilotTokenManager } from '../../../../platform/authentication/common/copilotTokenManager'; +import { CopilotTokenStore, ICopilotTokenStore } from '../../../../platform/authentication/common/copilotTokenStore'; +import { SimulationTestCopilotTokenManager } from '../../../../platform/authentication/test/node/simulationTestCopilotTokenManager'; +import { AuthProviderId, ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; +import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService'; +import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService'; +import { ILogService, LogServiceImpl } from '../../../../platform/log/common/logService'; +import { TestingServiceCollection } from '../../../../platform/test/node/services'; +import { raceTimeout } from '../../../../util/vs/base/common/async'; +import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; +import { Emitter, Event } from '../../../../util/vs/base/common/event'; +import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors'; +import { GitHubMcpDefinitionProvider } from '../../common/githubMcpDefinitionProvider'; + +/** + * Test implementation of authentication service that allows setting sessions dynamically + */ +class TestAuthenticationService extends BaseAuthenticationService { + private readonly _onDidChange = new Emitter(); + + constructor( + @ILogService logService: ILogService, + @ICopilotTokenStore tokenStore: ICopilotTokenStore, + @ICopilotTokenManager tokenManager: ICopilotTokenManager, + @IConfigurationService configurationService: IConfigurationService + ) { + super(logService, tokenStore, tokenManager, configurationService); + this._register(this._onDidChange); + } + + setPermissiveGitHubSession(session: AuthenticationSession | undefined): void { + this._permissiveGitHubSession = session; + this._onDidAuthenticationChange.fire(); + } + + getAnyGitHubSession(_options?: AuthenticationGetSessionOptions): Promise { + return Promise.resolve(this._anyGitHubSession); + } + + getPermissiveGitHubSession(options?: AuthenticationGetSessionOptions): Promise { + if (options?.createIfNone && !this._permissiveGitHubSession) { + throw new Error('No permissive GitHub session available'); + } + return Promise.resolve(this._permissiveGitHubSession); + } + + override getAnyAdoSession(_options?: AuthenticationGetSessionOptions): Promise { + return Promise.resolve(undefined); + } + + override getAdoAccessTokenBase64(_options?: AuthenticationGetSessionOptions): Promise { + return Promise.resolve(undefined); + } + + override async getCopilotToken(_force?: boolean): Promise { + return await super.getCopilotToken(_force); + } +} + +describe('GitHubMcpDefinitionProvider', () => { + let configService: InMemoryConfigurationService; + let authService: TestAuthenticationService; + let provider: GitHubMcpDefinitionProvider; + + /** + * Helper to create a provider with specific configuration values. + */ + async function createProvider(configOverrides?: { + authProvider?: AuthProviderId; + gheUri?: string; + toolsets?: string[]; + readonly?: boolean; + lockdown?: boolean; + hasPermissiveToken?: boolean; + }): Promise { + const serviceCollection = new TestingServiceCollection(); + configService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService()); + + // Set configuration values before creating the provider + if (configOverrides?.authProvider) { + await configService.setConfig(ConfigKey.Shared.AuthProvider, configOverrides.authProvider); + } + if (configOverrides?.gheUri) { + await configService.setNonExtensionConfig('github-enterprise.uri', configOverrides.gheUri); + } + if (configOverrides?.toolsets) { + await configService.setConfig(ConfigKey.GitHubMcpToolsets, configOverrides.toolsets); + } + if (configOverrides?.readonly !== undefined) { + await configService.setConfig(ConfigKey.GitHubMcpReadonly, configOverrides.readonly); + } + if (configOverrides?.lockdown !== undefined) { + await configService.setConfig(ConfigKey.GitHubMcpLockdown, configOverrides.lockdown); + } + + serviceCollection.define(IConfigurationService, configService); + serviceCollection.define(ICopilotTokenStore, new SyncDescriptor(CopilotTokenStore)); + serviceCollection.define(ICopilotTokenManager, new SyncDescriptor(SimulationTestCopilotTokenManager)); + serviceCollection.define(IAuthenticationService, new SyncDescriptor(TestAuthenticationService)); + serviceCollection.define(ILogService, new LogServiceImpl([])); + const accessor = serviceCollection.createTestingAccessor(); + + // Get the auth service and set up permissive token if needed + authService = accessor.get(IAuthenticationService) as TestAuthenticationService; + if (configOverrides?.hasPermissiveToken !== false) { + authService.setPermissiveGitHubSession({ accessToken: 'test-token', id: 'test-id', account: { id: 'test-account', label: 'test' }, scopes: [] }); + } + + return new GitHubMcpDefinitionProvider( + accessor.get(IConfigurationService), + accessor.get(IAuthenticationService), + accessor.get(ILogService) + ); + } + + beforeEach(async () => { + provider = await createProvider(); + }); + + describe('provideMcpServerDefinitions', () => { + test('returns GitHub.com configuration by default', () => { + const definitions = provider.provideMcpServerDefinitions(); + + expect(definitions).toHaveLength(1); + expect(definitions[0].label).toBe('GitHub'); + expect(definitions[0].uri.toString()).toBe('https://api.githubcopilot.com/mcp/'); + }); + + test('returns GitHub Enterprise configuration when auth provider is set to GHE', async () => { + const gheUri = 'https://github.enterprise.com'; + const gheProvider = await createProvider({ + authProvider: AuthProviderId.GitHubEnterprise, + gheUri + }); + + const definitions = gheProvider.provideMcpServerDefinitions(); + + expect(definitions).toHaveLength(1); + expect(definitions[0].label).toBe('GitHub Enterprise'); + // Should include the copilot-api. prefix + expect(definitions[0].uri.toString()).toBe('https://copilot-api.github.enterprise.com/mcp/'); + }); + + test('includes configured toolsets in headers', async () => { + const toolsets = ['code_search', 'issues', 'pull_requests']; + const providerWithToolsets = await createProvider({ toolsets }); + + const definitions = providerWithToolsets.provideMcpServerDefinitions(); + + expect(definitions[0].headers['X-MCP-Toolsets']).toBe('code_search,issues,pull_requests'); + }); + + test('handles empty toolsets configuration', async () => { + const providerWithEmptyToolsets = await createProvider({ toolsets: [] }); + + const definitions = providerWithEmptyToolsets.provideMcpServerDefinitions(); + + expect(definitions[0].headers['X-MCP-Toolsets']).toBeUndefined(); + }); + + test('version is the sorted toolset string', async () => { + const toolsets = ['pull_requests', 'code_search', 'issues']; + const providerWithToolsets = await createProvider({ toolsets }); + const definitions = providerWithToolsets.provideMcpServerDefinitions(); + // Sorted toolsets string + expect(definitions[0].version).toBe('code_search,issues,pull_requests'); + }); + + test('throws when GHE is configured but URI is missing', async () => { + const gheProviderWithoutUri = await createProvider({ + authProvider: AuthProviderId.GitHubEnterprise + // Don't set the GHE URI + }); + + expect(() => gheProviderWithoutUri.provideMcpServerDefinitions()).toThrow('GitHub Enterprise URI is not configured.'); + }); + + test('includes X-MCP-Readonly header when readonly is true', async () => { + const readonlyProvider = await createProvider({ readonly: true }); + + const definitions = readonlyProvider.provideMcpServerDefinitions(); + + expect(definitions[0].headers['X-MCP-Readonly']).toBe('true'); + }); + + test('does not include X-MCP-Readonly header when readonly is false', async () => { + const nonReadonlyProvider = await createProvider({ readonly: false }); + + const definitions = nonReadonlyProvider.provideMcpServerDefinitions(); + + expect(definitions[0].headers['X-MCP-Readonly']).toBeUndefined(); + }); + + test('includes X-MCP-Lockdown header when lockdown is true', async () => { + const lockdownProvider = await createProvider({ lockdown: true }); + + const definitions = lockdownProvider.provideMcpServerDefinitions(); + + expect(definitions[0].headers['X-MCP-Lockdown']).toBe('true'); + }); + + test('does not include X-MCP-Lockdown header when lockdown is false', async () => { + const nonLockdownProvider = await createProvider({ lockdown: false }); + + const definitions = nonLockdownProvider.provideMcpServerDefinitions(); + + expect(definitions[0].headers['X-MCP-Lockdown']).toBeUndefined(); + }); + + test('includes both readonly and lockdown headers when both are true', async () => { + const bothProvider = await createProvider({ readonly: true, lockdown: true }); + + const definitions = bothProvider.provideMcpServerDefinitions(); + + expect(definitions[0].headers['X-MCP-Readonly']).toBe('true'); + expect(definitions[0].headers['X-MCP-Lockdown']).toBe('true'); + }); + + test('version includes readonly flag when readonly is true', async () => { + const readonlyProvider = await createProvider({ readonly: true }); + + const definitions = readonlyProvider.provideMcpServerDefinitions(); + + expect(definitions[0].version).toBe('default|readonly'); + }); + + test('version includes lockdown flag when lockdown is true', async () => { + const lockdownProvider = await createProvider({ lockdown: true }); + + const definitions = lockdownProvider.provideMcpServerDefinitions(); + + expect(definitions[0].version).toBe('default|lockdown'); + }); + + test('version includes both flags when both readonly and lockdown are true', async () => { + const bothProvider = await createProvider({ readonly: true, lockdown: true }); + + const definitions = bothProvider.provideMcpServerDefinitions(); + + expect(definitions[0].version).toBe('default|readonly|lockdown'); + }); + + test('version is just toolsets when readonly and lockdown are false', async () => { + const toolsets = ['issues', 'pull_requests']; + const normalProvider = await createProvider({ toolsets, readonly: false, lockdown: false }); + + const definitions = normalProvider.provideMcpServerDefinitions(); + + expect(definitions[0].version).toBe('issues,pull_requests'); + }); + + test('version with empty toolsets and readonly', async () => { + const readonlyEmptyProvider = await createProvider({ toolsets: [], readonly: true }); + + const definitions = readonlyEmptyProvider.provideMcpServerDefinitions(); + + expect(definitions[0].version).toBe('0|readonly'); + }); + }); + + describe('onDidChangeMcpServerDefinitions', () => { + test('fires when toolsets configuration changes', async () => { + const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions); + + await configService.setConfig(ConfigKey.GitHubMcpToolsets, ['new_toolset']); + + await eventPromise; + }); + + test('fires when auth provider configuration changes', async () => { + const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions); + + await configService.setConfig(ConfigKey.Shared.AuthProvider, AuthProviderId.GitHubEnterprise); + + await eventPromise; + }); + + test('fires when GHE URI configuration changes', async () => { + await configService.setConfig(ConfigKey.Shared.AuthProvider, AuthProviderId.GitHubEnterprise); + await configService.setNonExtensionConfig('github-enterprise.uri', 'https://old.enterprise.com'); + + const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions); + + await configService.setNonExtensionConfig('github-enterprise.uri', 'https://new.enterprise.com'); + + await eventPromise; + }); + + test('does not fire for unrelated configuration changes', async () => { + let eventFired = false; + const handler = () => { + eventFired = true; + }; + const disposable = provider.onDidChangeMcpServerDefinitions(handler); + + await configService.setNonExtensionConfig('some.unrelated.config', 'value'); + + await raceTimeout(Promise.resolve(), 50); + + expect(eventFired).toBe(false); + disposable.dispose(); + }); + + test('fires when readonly configuration changes', async () => { + const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions); + + await configService.setConfig(ConfigKey.GitHubMcpReadonly, true); + + await eventPromise; + }); + + test('fires when lockdown configuration changes', async () => { + const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions); + + await configService.setConfig(ConfigKey.GitHubMcpLockdown, true); + + await eventPromise; + }); + }); + + describe('edge cases', () => { + test('uses default toolsets value when not configured', () => { + const definitions = provider.provideMcpServerDefinitions(); + expect(definitions).toHaveLength(1); + expect(definitions[0].headers['X-MCP-Toolsets']).toBe('default'); + expect(definitions[0].version).toBe('default'); + }); + }); + + describe('resolveMcpServerDefinition', () => { + test('adds authorization header when permissive token is available', async () => { + const definitions = provider.provideMcpServerDefinitions(); + const resolved = await provider.resolveMcpServerDefinition(definitions[0], CancellationToken.None); + + expect(resolved).toBeDefined(); + expect(resolved.headers['Authorization']).toBe('Bearer test-token'); + }); + + test('throws when no permissive token is available and session cannot be created', async () => { + const providerWithoutToken = await createProvider({ hasPermissiveToken: false }); + const definitions = providerWithoutToken.provideMcpServerDefinitions(); + + // Since the mock returns undefined and the implementation uses session!.accessToken, + // this will throw when trying to access accessToken on undefined + await expect(providerWithoutToken.resolveMcpServerDefinition(definitions[0], CancellationToken.None)).rejects.toThrow(); + }); + }); + + describe('authentication change events', () => { + test('fires onDidChangeMcpServerDefinitions when token becomes available', async () => { + const providerWithoutToken = await createProvider({ hasPermissiveToken: false }); + const eventPromise = Event.toPromise(providerWithoutToken.onDidChangeMcpServerDefinitions); + + authService.setPermissiveGitHubSession({ accessToken: 'new-token', id: 'new-id', account: { id: 'new-account', label: 'new' }, scopes: [] }); + + await eventPromise; + }); + + test('fires onDidChangeMcpServerDefinitions when token is removed', async () => { + const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions); + + authService.setPermissiveGitHubSession(undefined); + + await eventPromise; + }); + + test('does not fire when token changes but availability remains the same', async () => { + let eventFired = false; + const handler = () => { + eventFired = true; + }; + const disposable = provider.onDidChangeMcpServerDefinitions(handler); + + // Change the token value but keep it defined + authService.setPermissiveGitHubSession({ accessToken: 'different-token', id: 'different-id', account: { id: 'different-account', label: 'different' }, scopes: [] }); + + await raceTimeout(Promise.resolve(), 50); + + expect(eventFired).toBe(false); + disposable.dispose(); + }); + }); +}); diff --git a/extensions/copilot/src/extension/githubMcp/vscode-node/githubMcp.contribution.ts b/extensions/copilot/src/extension/githubMcp/vscode-node/githubMcp.contribution.ts new file mode 100644 index 00000000000..485af2f652c --- /dev/null +++ b/extensions/copilot/src/extension/githubMcp/vscode-node/githubMcp.contribution.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { lm } from 'vscode'; +import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { ILogService } from '../../../platform/log/common/logService'; +import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; +import { Disposable, IDisposable } from '../../../util/vs/base/common/lifecycle'; +import { GitHubMcpDefinitionProvider } from '../common/githubMcpDefinitionProvider'; + +export class GitHubMcpContrib extends Disposable { + private disposable?: IDisposable; + private definitionProvider?: GitHubMcpDefinitionProvider; + + constructor( + @IConfigurationService private readonly configurationService: IConfigurationService, + @IExperimentationService private readonly experimentationService: IExperimentationService, + @IAuthenticationService private readonly authenticationService: IAuthenticationService, + @ILogService private readonly logService: ILogService + ) { + super(); + this._registerConfigurationListener(); + if (this.enabled) { + void this._registerGitHubMcpDefinitionProvider(); + } + } + + private _registerConfigurationListener() { + this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ConfigKey.GitHubMcpEnabled.fullyQualifiedId)) { + if (this.enabled) { + void this._registerGitHubMcpDefinitionProvider(); + } else { + this.disposable?.dispose(); + this.disposable = undefined; + this.definitionProvider = undefined; + } + } + }); + } + + private async _registerGitHubMcpDefinitionProvider() { + if (!this.definitionProvider) { + // Register the GitHub MCP Definition Provider + this.definitionProvider = new GitHubMcpDefinitionProvider(this.configurationService, this.authenticationService, this.logService); + this.disposable = lm.registerMcpServerDefinitionProvider('github', this.definitionProvider); + } + } + + private get enabled(): boolean { + return this.configurationService.getExperimentBasedConfig(ConfigKey.GitHubMcpEnabled, this.experimentationService); + } +} diff --git a/extensions/copilot/src/platform/authentication/common/authentication.ts b/extensions/copilot/src/platform/authentication/common/authentication.ts index 365fa87a848..03467b7375a 100644 --- a/extensions/copilot/src/platform/authentication/common/authentication.ts +++ b/extensions/copilot/src/platform/authentication/common/authentication.ts @@ -7,7 +7,7 @@ import { createServiceIdentifier } from '../../../util/common/services'; import { Emitter, Event } from '../../../util/vs/base/common/event'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { derived } from '../../../util/vs/base/common/observableInternal'; -import { AuthPermissionMode, ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; +import { AuthPermissionMode, AuthProviderId, ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; import { ILogService } from '../../log/common/logService'; import { CopilotToken } from './copilotToken'; import { ICopilotTokenManager } from './copilotTokenManager'; @@ -305,3 +305,11 @@ export abstract class BaseAuthenticationService extends Disposable implements IA this._logService.debug('Finished handling auth change event.'); } } + +export function authProviderId(configurationService: IConfigurationService): AuthProviderId { + return ( + configurationService.getConfig(ConfigKey.Shared.AuthProvider) === AuthProviderId.GitHubEnterprise + ? AuthProviderId.GitHubEnterprise + : AuthProviderId.GitHub + ); +} diff --git a/extensions/copilot/src/platform/authentication/vscode-node/authenticationService.ts b/extensions/copilot/src/platform/authentication/vscode-node/authenticationService.ts index dd0fd79114c..b3b0661aae8 100644 --- a/extensions/copilot/src/platform/authentication/vscode-node/authenticationService.ts +++ b/extensions/copilot/src/platform/authentication/vscode-node/authenticationService.ts @@ -8,10 +8,10 @@ import { TaskSingler } from '../../../util/common/taskSingler'; import { AuthProviderId, IConfigurationService } from '../../configuration/common/configurationService'; import { IDomainService } from '../../endpoint/common/domainService'; import { ILogService } from '../../log/common/logService'; -import { BaseAuthenticationService } from '../common/authentication'; +import { authProviderId, BaseAuthenticationService } from '../common/authentication'; import { ICopilotTokenManager } from '../common/copilotTokenManager'; import { ICopilotTokenStore } from '../common/copilotTokenStore'; -import { authProviderId, getAlignedSession, getAnyAuthSession } from './session'; +import { getAlignedSession, getAnyAuthSession } from './session'; export class AuthenticationService extends BaseAuthenticationService { private _taskSingler = new TaskSingler(); diff --git a/extensions/copilot/src/platform/authentication/vscode-node/session.ts b/extensions/copilot/src/platform/authentication/vscode-node/session.ts index c0a712802bc..7853f4eee91 100644 --- a/extensions/copilot/src/platform/authentication/vscode-node/session.ts +++ b/extensions/copilot/src/platform/authentication/vscode-node/session.ts @@ -3,11 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AuthenticationGetSessionOptions, AuthenticationSession, AuthenticationSessionsChangeEvent, authentication } from 'vscode'; -import { URI } from '../../../util/vs/base/common/uri'; -import { AuthPermissionMode, AuthProviderId, ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; -import { GITHUB_SCOPE_ALIGNED, GITHUB_SCOPE_READ_USER, GITHUB_SCOPE_USER_EMAIL, MinimalModeError } from '../common/authentication'; +import { authentication, AuthenticationGetSessionOptions, AuthenticationSession, AuthenticationSessionsChangeEvent } from 'vscode'; import { mixin } from '../../../util/vs/base/common/objects'; +import { URI } from '../../../util/vs/base/common/uri'; +import { AuthPermissionMode, ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; +import { authProviderId, GITHUB_SCOPE_ALIGNED, GITHUB_SCOPE_READ_USER, GITHUB_SCOPE_USER_EMAIL, MinimalModeError } from '../common/authentication'; export const SESSION_LOGIN_MESSAGE = 'You are not signed in to GitHub. Please sign in to use Copilot.'; // These types are subsets of the "real" types AuthenticationSessionAccountInformation and @@ -22,14 +22,6 @@ export type CopilotAuthenticationSession = { account: CopilotAuthenticationSessionAccountInformation; }; -export function authProviderId(configurationService: IConfigurationService): AuthProviderId { - return ( - configurationService.getConfig(ConfigKey.Shared.AuthProvider) === AuthProviderId.GitHubEnterprise - ? AuthProviderId.GitHubEnterprise - : AuthProviderId.GitHub - ); -} - async function getAuthSession(providerId: string, defaultScopes: string[], getSilentSession: () => Promise, options: AuthenticationGetSessionOptions = {}) { const accounts = await authentication.getAccounts(providerId); if (!accounts.length) { diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index c4d48b6a910..8bdafefe06d 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -822,6 +822,11 @@ export namespace ConfigKey { export const CompletionsFetcher = defineSetting('chat.completionsFetcher', ConfigType.ExperimentBased, undefined); export const NextEditSuggestionsFetcher = defineSetting('chat.nesFetcher', ConfigType.ExperimentBased, undefined); + + export const GitHubMcpEnabled = defineSetting('chat.githubMcpServer.enabled', ConfigType.ExperimentBased, false); + export const GitHubMcpToolsets = defineSetting('chat.githubMcpServer.toolsets', ConfigType.Simple, ['default']); + export const GitHubMcpReadonly = defineSetting('chat.githubMcpServer.readonly', ConfigType.Simple, false); + export const GitHubMcpLockdown = defineSetting('chat.githubMcpServer.lockdown', ConfigType.Simple, false); } export function getAllConfigKeys(): string[] { diff --git a/extensions/copilot/src/platform/configuration/test/common/inMemoryConfigurationService.ts b/extensions/copilot/src/platform/configuration/test/common/inMemoryConfigurationService.ts index 45f19c36326..a72d6673a47 100644 --- a/extensions/copilot/src/platform/configuration/test/common/inMemoryConfigurationService.ts +++ b/extensions/copilot/src/platform/configuration/test/common/inMemoryConfigurationService.ts @@ -46,11 +46,17 @@ export class InMemoryConfigurationService extends AbstractConfigurationService { override setConfig(key: BaseConfig, value: T): Promise { this.overrides.set(key, value); + this._onDidChangeConfiguration.fire({ + affectsConfiguration: (section: string) => section === key.fullyQualifiedId || key.fullyQualifiedId.startsWith(section + '.') + }); return Promise.resolve(); } setNonExtensionConfig(key: string, value: T): Promise { this.nonExtensionOverrides.set(key, value); + this._onDidChangeConfiguration.fire({ + affectsConfiguration: (section: string) => section === key || key.startsWith(section + '.') + }); return Promise.resolve(); }