mirror of
https://github.com/microsoft/vscode.git
synced 2026-09-14 07:12:00 +01:00
Support GitHub MCP in-box (#2051)
* Support GitHub MCP inbox Fixes https://github.com/microsoft/vscode/issues/254836 * Be auth provider aware, and bump version on every config change * tests and fixed ghe url * Move things around so the layers make more sense * move settings to experimental section * move def prov * who needs new APIs anyway? * Ask for auth * fix tests * disable `#githubRepo` when GH MCP is enabled * Include settings for readonly & lockdown * misc * fix test
This commit is contained in:
committed by
GitHub
parent
8aaacb4574
commit
2db395aa66
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<McpHttpServerDefinition> {
|
||||
|
||||
readonly onDidChangeMcpServerDefinitions: Event<void>;
|
||||
|
||||
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<string[]>(ConfigKey.GitHubMcpToolsets);
|
||||
}
|
||||
|
||||
private get readonly(): boolean {
|
||||
return this.configurationService.getConfig<boolean>(ConfigKey.GitHubMcpReadonly);
|
||||
}
|
||||
|
||||
private get lockdown(): boolean {
|
||||
return this.configurationService.getConfig<boolean>(ConfigKey.GitHubMcpLockdown);
|
||||
}
|
||||
|
||||
private get gheConfig(): string | undefined {
|
||||
return this.configurationService.getNonExtensionConfig<string>(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<string, string> = {};
|
||||
// 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<McpHttpServerDefinition> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+392
@@ -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<void>();
|
||||
|
||||
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<AuthenticationSession | undefined> {
|
||||
return Promise.resolve(this._anyGitHubSession);
|
||||
}
|
||||
|
||||
getPermissiveGitHubSession(options?: AuthenticationGetSessionOptions): Promise<AuthenticationSession | undefined> {
|
||||
if (options?.createIfNone && !this._permissiveGitHubSession) {
|
||||
throw new Error('No permissive GitHub session available');
|
||||
}
|
||||
return Promise.resolve(this._permissiveGitHubSession);
|
||||
}
|
||||
|
||||
override getAnyAdoSession(_options?: AuthenticationGetSessionOptions): Promise<AuthenticationSession | undefined> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
override getAdoAccessTokenBase64(_options?: AuthenticationGetSessionOptions): Promise<string | undefined> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
override async getCopilotToken(_force?: boolean): Promise<CopilotToken> {
|
||||
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<GitHubMcpDefinitionProvider> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<AuthenticationSession | undefined>();
|
||||
|
||||
@@ -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<AuthenticationSession | undefined>, options: AuthenticationGetSessionOptions = {}) {
|
||||
const accounts = await authentication.getAccounts(providerId);
|
||||
if (!accounts.length) {
|
||||
|
||||
@@ -822,6 +822,11 @@ export namespace ConfigKey {
|
||||
|
||||
export const CompletionsFetcher = defineSetting<FetcherId | undefined>('chat.completionsFetcher', ConfigType.ExperimentBased, undefined);
|
||||
export const NextEditSuggestionsFetcher = defineSetting<FetcherId | undefined>('chat.nesFetcher', ConfigType.ExperimentBased, undefined);
|
||||
|
||||
export const GitHubMcpEnabled = defineSetting<boolean>('chat.githubMcpServer.enabled', ConfigType.ExperimentBased, false);
|
||||
export const GitHubMcpToolsets = defineSetting<string[]>('chat.githubMcpServer.toolsets', ConfigType.Simple, ['default']);
|
||||
export const GitHubMcpReadonly = defineSetting<boolean>('chat.githubMcpServer.readonly', ConfigType.Simple, false);
|
||||
export const GitHubMcpLockdown = defineSetting<boolean>('chat.githubMcpServer.lockdown', ConfigType.Simple, false);
|
||||
}
|
||||
|
||||
export function getAllConfigKeys(): string[] {
|
||||
|
||||
+6
@@ -46,11 +46,17 @@ export class InMemoryConfigurationService extends AbstractConfigurationService {
|
||||
|
||||
override setConfig<T>(key: BaseConfig<T>, value: T): Promise<void> {
|
||||
this.overrides.set(key, value);
|
||||
this._onDidChangeConfiguration.fire({
|
||||
affectsConfiguration: (section: string) => section === key.fullyQualifiedId || key.fullyQualifiedId.startsWith(section + '.')
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
setNonExtensionConfig<T>(key: string, value: T): Promise<void> {
|
||||
this.nonExtensionOverrides.set(key, value);
|
||||
this._onDidChangeConfiguration.fire({
|
||||
affectsConfiguration: (section: string) => section === key || key.startsWith(section + '.')
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user