Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/githubMcp/test/node/githubMcpDefinitionProvider.spec.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { beforeEach, describe, expect, test } from 'vitest';
7
import type { AuthenticationGetSessionOptions, AuthenticationSession } from 'vscode';
8
import { BaseAuthenticationService, IAuthenticationService, StrictAuthenticationPresentationOptions } from '../../../../platform/authentication/common/authentication';
9
import { CopilotToken } from '../../../../platform/authentication/common/copilotToken';
10
import { ICopilotTokenManager } from '../../../../platform/authentication/common/copilotTokenManager';
11
import { CopilotTokenStore, ICopilotTokenStore } from '../../../../platform/authentication/common/copilotTokenStore';
12
import { SimulationTestCopilotTokenManager } from '../../../../platform/authentication/test/node/simulationTestCopilotTokenManager';
13
import { AuthProviderId, ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
14
import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';
15
import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';
16
import { ILogService, LogServiceImpl } from '../../../../platform/log/common/logService';
17
import { TestingServiceCollection } from '../../../../platform/test/node/services';
18
import { raceTimeout } from '../../../../util/vs/base/common/async';
19
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
20
import { Emitter, Event } from '../../../../util/vs/base/common/event';
21
import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors';
22
import { GitHubMcpDefinitionProvider } from '../../common/githubMcpDefinitionProvider';
23
24
/**
25
* Test implementation of authentication service that allows setting sessions dynamically
26
*/
27
class TestAuthenticationService extends BaseAuthenticationService {
28
private readonly _onDidChange = new Emitter<void>();
29
30
constructor(
31
@ILogService logService: ILogService,
32
@ICopilotTokenStore tokenStore: ICopilotTokenStore,
33
@ICopilotTokenManager tokenManager: ICopilotTokenManager,
34
@IConfigurationService configurationService: IConfigurationService
35
) {
36
super(logService, tokenStore, tokenManager, configurationService);
37
this._register(this._onDidChange);
38
}
39
40
setPermissiveGitHubSession(session: AuthenticationSession | undefined): void {
41
this._permissiveGitHubSession = session;
42
this.fireAuthenticationChange('setPermissiveGitHubSession');
43
}
44
45
override getGitHubSession(kind: 'permissive' | 'any', options: AuthenticationGetSessionOptions & { createIfNone: StrictAuthenticationPresentationOptions }): Promise<AuthenticationSession>;
46
override getGitHubSession(kind: 'permissive' | 'any', options: AuthenticationGetSessionOptions & { forceNewSession: StrictAuthenticationPresentationOptions }): Promise<AuthenticationSession>;
47
override getGitHubSession(kind: 'permissive' | 'any', options?: AuthenticationGetSessionOptions): Promise<AuthenticationSession | undefined> {
48
if (kind === 'permissive') {
49
if (options?.createIfNone && !this._permissiveGitHubSession) {
50
throw new Error('No permissive GitHub session available');
51
}
52
return Promise.resolve(this._permissiveGitHubSession);
53
} else {
54
return Promise.resolve(this._anyGitHubSession);
55
}
56
}
57
58
override getAnyAdoSession(_options?: AuthenticationGetSessionOptions): Promise<AuthenticationSession | undefined> {
59
return Promise.resolve(undefined);
60
}
61
62
override getAdoAccessTokenBase64(_options?: AuthenticationGetSessionOptions): Promise<string | undefined> {
63
return Promise.resolve(undefined);
64
}
65
66
override async getCopilotToken(_force?: boolean): Promise<CopilotToken> {
67
return await super.getCopilotToken(_force);
68
}
69
}
70
71
describe('GitHubMcpDefinitionProvider', () => {
72
let configService: InMemoryConfigurationService;
73
let authService: TestAuthenticationService;
74
let provider: GitHubMcpDefinitionProvider;
75
76
/**
77
* Helper to create a provider with specific configuration values.
78
*/
79
async function createProvider(configOverrides?: {
80
authProvider?: AuthProviderId;
81
gheUri?: string;
82
toolsets?: string[];
83
readonly?: boolean;
84
lockdown?: boolean;
85
channel?: ConfigKey.GitHubMcpChannelValue;
86
hasPermissiveToken?: boolean;
87
}): Promise<GitHubMcpDefinitionProvider> {
88
const serviceCollection = new TestingServiceCollection();
89
configService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());
90
91
// Set configuration values before creating the provider
92
if (configOverrides?.authProvider) {
93
await configService.setConfig(ConfigKey.Shared.AuthProvider, configOverrides.authProvider);
94
}
95
if (configOverrides?.gheUri) {
96
await configService.setNonExtensionConfig('github-enterprise.uri', configOverrides.gheUri);
97
}
98
if (configOverrides?.toolsets) {
99
await configService.setConfig(ConfigKey.GitHubMcpToolsets, configOverrides.toolsets);
100
}
101
if (configOverrides?.readonly !== undefined) {
102
await configService.setConfig(ConfigKey.GitHubMcpReadonly, configOverrides.readonly);
103
}
104
if (configOverrides?.lockdown !== undefined) {
105
await configService.setConfig(ConfigKey.GitHubMcpLockdown, configOverrides.lockdown);
106
}
107
if (configOverrides?.channel !== undefined) {
108
await configService.setConfig(ConfigKey.GitHubMcpChannel, configOverrides.channel);
109
}
110
111
serviceCollection.define(IConfigurationService, configService);
112
serviceCollection.define(ICopilotTokenStore, new SyncDescriptor(CopilotTokenStore));
113
serviceCollection.define(ICopilotTokenManager, new SyncDescriptor(SimulationTestCopilotTokenManager));
114
serviceCollection.define(IAuthenticationService, new SyncDescriptor(TestAuthenticationService));
115
serviceCollection.define(ILogService, new LogServiceImpl([]));
116
const accessor = serviceCollection.createTestingAccessor();
117
118
// Get the auth service and set up permissive token if needed
119
authService = accessor.get(IAuthenticationService) as TestAuthenticationService;
120
if (configOverrides?.hasPermissiveToken !== false) {
121
authService.setPermissiveGitHubSession({ accessToken: 'test-token', id: 'test-id', account: { id: 'test-account', label: 'test' }, scopes: [] });
122
}
123
124
return new GitHubMcpDefinitionProvider(
125
accessor.get(IConfigurationService),
126
accessor.get(IAuthenticationService),
127
accessor.get(ILogService)
128
);
129
}
130
131
beforeEach(async () => {
132
provider = await createProvider();
133
});
134
135
describe('provideMcpServerDefinitions', () => {
136
test('returns GitHub.com configuration by default', () => {
137
const definitions = provider.provideMcpServerDefinitions();
138
139
expect(definitions).toHaveLength(1);
140
expect(definitions[0].label).toBe('GitHub');
141
expect(definitions[0].uri.toString()).toBe('https://api.githubcopilot.com/mcp/');
142
});
143
144
test('returns GitHub Enterprise configuration when auth provider is set to GHE', async () => {
145
const gheUri = 'https://github.enterprise.com';
146
const gheProvider = await createProvider({
147
authProvider: AuthProviderId.GitHubEnterprise,
148
gheUri
149
});
150
151
const definitions = gheProvider.provideMcpServerDefinitions();
152
153
expect(definitions).toHaveLength(1);
154
expect(definitions[0].label).toBe('GitHub Enterprise');
155
// Should include the copilot-api. prefix
156
expect(definitions[0].uri.toString()).toBe('https://copilot-api.github.enterprise.com/mcp/');
157
});
158
159
test('includes configured toolsets in headers', async () => {
160
const toolsets = ['code_search', 'issues', 'pull_requests'];
161
const providerWithToolsets = await createProvider({ toolsets });
162
163
const definitions = providerWithToolsets.provideMcpServerDefinitions();
164
165
expect(definitions[0].headers['X-MCP-Toolsets']).toBe('code_search,issues,pull_requests');
166
});
167
168
test('handles empty toolsets configuration', async () => {
169
const providerWithEmptyToolsets = await createProvider({ toolsets: [] });
170
171
const definitions = providerWithEmptyToolsets.provideMcpServerDefinitions();
172
173
expect(definitions[0].headers['X-MCP-Toolsets']).toBeUndefined();
174
});
175
176
test('version is the sorted toolset string', async () => {
177
const toolsets = ['pull_requests', 'code_search', 'issues'];
178
const providerWithToolsets = await createProvider({ toolsets });
179
const definitions = providerWithToolsets.provideMcpServerDefinitions();
180
// Sorted toolsets string
181
expect(definitions[0].version).toBe('code_search,issues,pull_requests');
182
});
183
184
test('throws when GHE is configured but URI is missing', async () => {
185
const gheProviderWithoutUri = await createProvider({
186
authProvider: AuthProviderId.GitHubEnterprise
187
// Don't set the GHE URI
188
});
189
190
expect(() => gheProviderWithoutUri.provideMcpServerDefinitions()).toThrow('GitHub Enterprise URI is not configured.');
191
});
192
193
test('includes X-MCP-Readonly header when readonly is true', async () => {
194
const readonlyProvider = await createProvider({ readonly: true });
195
196
const definitions = readonlyProvider.provideMcpServerDefinitions();
197
198
expect(definitions[0].headers['X-MCP-Readonly']).toBe('true');
199
});
200
201
test('does not include X-MCP-Readonly header when readonly is false', async () => {
202
const nonReadonlyProvider = await createProvider({ readonly: false });
203
204
const definitions = nonReadonlyProvider.provideMcpServerDefinitions();
205
206
expect(definitions[0].headers['X-MCP-Readonly']).toBeUndefined();
207
});
208
209
test('includes X-MCP-Lockdown header when lockdown is true', async () => {
210
const lockdownProvider = await createProvider({ lockdown: true });
211
212
const definitions = lockdownProvider.provideMcpServerDefinitions();
213
214
expect(definitions[0].headers['X-MCP-Lockdown']).toBe('true');
215
});
216
217
test('does not include X-MCP-Lockdown header when lockdown is false', async () => {
218
const nonLockdownProvider = await createProvider({ lockdown: false });
219
220
const definitions = nonLockdownProvider.provideMcpServerDefinitions();
221
222
expect(definitions[0].headers['X-MCP-Lockdown']).toBeUndefined();
223
});
224
225
test('includes both readonly and lockdown headers when both are true', async () => {
226
const bothProvider = await createProvider({ readonly: true, lockdown: true });
227
228
const definitions = bothProvider.provideMcpServerDefinitions();
229
230
expect(definitions[0].headers['X-MCP-Readonly']).toBe('true');
231
expect(definitions[0].headers['X-MCP-Lockdown']).toBe('true');
232
});
233
234
test('version includes readonly flag when readonly is true', async () => {
235
const readonlyProvider = await createProvider({ readonly: true });
236
237
const definitions = readonlyProvider.provideMcpServerDefinitions();
238
239
expect(definitions[0].version).toBe('default|readonly');
240
});
241
242
test('version includes lockdown flag when lockdown is true', async () => {
243
const lockdownProvider = await createProvider({ lockdown: true });
244
245
const definitions = lockdownProvider.provideMcpServerDefinitions();
246
247
expect(definitions[0].version).toBe('default|lockdown');
248
});
249
250
test('version includes both flags when both readonly and lockdown are true', async () => {
251
const bothProvider = await createProvider({ readonly: true, lockdown: true });
252
253
const definitions = bothProvider.provideMcpServerDefinitions();
254
255
expect(definitions[0].version).toBe('default|readonly|lockdown');
256
});
257
258
test('includes X-MCP-Insiders header when channel is insiders', async () => {
259
const insidersProvider = await createProvider({ channel: 'insiders' });
260
261
const definitions = insidersProvider.provideMcpServerDefinitions();
262
263
expect(definitions[0].headers['X-MCP-Insiders']).toBe('true');
264
});
265
266
test('does not include X-MCP-Insiders header when channel is stable', async () => {
267
const stableProvider = await createProvider({ channel: 'stable' });
268
269
const definitions = stableProvider.provideMcpServerDefinitions();
270
271
expect(definitions[0].headers['X-MCP-Insiders']).toBeUndefined();
272
});
273
274
test('version includes insiders flag when channel is insiders', async () => {
275
const insidersProvider = await createProvider({ channel: 'insiders' });
276
277
const definitions = insidersProvider.provideMcpServerDefinitions();
278
279
expect(definitions[0].version).toBe('default|insiders');
280
});
281
282
test('version includes all flags when readonly, lockdown, and insiders are set', async () => {
283
const allFlagsProvider = await createProvider({ readonly: true, lockdown: true, channel: 'insiders' });
284
285
const definitions = allFlagsProvider.provideMcpServerDefinitions();
286
287
expect(definitions[0].version).toBe('default|readonly|lockdown|insiders');
288
});
289
290
test('version is just toolsets when readonly and lockdown are false', async () => {
291
const toolsets = ['issues', 'pull_requests'];
292
const normalProvider = await createProvider({ toolsets, readonly: false, lockdown: false });
293
294
const definitions = normalProvider.provideMcpServerDefinitions();
295
296
expect(definitions[0].version).toBe('issues,pull_requests');
297
});
298
299
test('version with empty toolsets and readonly', async () => {
300
const readonlyEmptyProvider = await createProvider({ toolsets: [], readonly: true });
301
302
const definitions = readonlyEmptyProvider.provideMcpServerDefinitions();
303
304
expect(definitions[0].version).toBe('0|readonly');
305
});
306
});
307
308
describe('onDidChangeMcpServerDefinitions', () => {
309
test('fires when toolsets configuration changes', async () => {
310
const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions);
311
312
await configService.setConfig(ConfigKey.GitHubMcpToolsets, ['new_toolset']);
313
314
await eventPromise;
315
});
316
317
test('fires when auth provider configuration changes', async () => {
318
const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions);
319
320
await configService.setConfig(ConfigKey.Shared.AuthProvider, AuthProviderId.GitHubEnterprise);
321
322
await eventPromise;
323
});
324
325
test('fires when GHE URI configuration changes', async () => {
326
await configService.setConfig(ConfigKey.Shared.AuthProvider, AuthProviderId.GitHubEnterprise);
327
await configService.setNonExtensionConfig('github-enterprise.uri', 'https://old.enterprise.com');
328
329
const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions);
330
331
await configService.setNonExtensionConfig('github-enterprise.uri', 'https://new.enterprise.com');
332
333
await eventPromise;
334
});
335
336
test('does not fire for unrelated configuration changes', async () => {
337
let eventFired = false;
338
const handler = () => {
339
eventFired = true;
340
};
341
const disposable = provider.onDidChangeMcpServerDefinitions(handler);
342
343
await configService.setNonExtensionConfig('some.unrelated.config', 'value');
344
345
await raceTimeout(Promise.resolve(), 50);
346
347
expect(eventFired).toBe(false);
348
disposable.dispose();
349
});
350
351
test('fires when readonly configuration changes', async () => {
352
const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions);
353
354
await configService.setConfig(ConfigKey.GitHubMcpReadonly, true);
355
356
await eventPromise;
357
});
358
359
test('fires when lockdown configuration changes', async () => {
360
const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions);
361
362
await configService.setConfig(ConfigKey.GitHubMcpLockdown, true);
363
364
await eventPromise;
365
});
366
367
test('fires when channel configuration changes', async () => {
368
const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions);
369
370
await configService.setConfig(ConfigKey.GitHubMcpChannel, 'insiders');
371
372
await eventPromise;
373
});
374
});
375
376
describe('edge cases', () => {
377
test('uses default toolsets value when not configured', () => {
378
const definitions = provider.provideMcpServerDefinitions();
379
expect(definitions).toHaveLength(1);
380
expect(definitions[0].headers['X-MCP-Toolsets']).toBe('default');
381
expect(definitions[0].version).toBe('default');
382
});
383
});
384
385
describe('resolveMcpServerDefinition', () => {
386
test('adds authorization header when permissive token is available', async () => {
387
const definitions = provider.provideMcpServerDefinitions();
388
const resolved = await provider.resolveMcpServerDefinition(definitions[0], CancellationToken.None);
389
390
expect(resolved).toBeDefined();
391
expect(resolved.headers['Authorization']).toBe('Bearer test-token');
392
});
393
394
test('throws when no permissive token is available and session cannot be created', async () => {
395
const providerWithoutToken = await createProvider({ hasPermissiveToken: false });
396
const definitions = providerWithoutToken.provideMcpServerDefinitions();
397
398
// Since the mock returns undefined and the implementation uses session!.accessToken,
399
// this will throw when trying to access accessToken on undefined
400
await expect(providerWithoutToken.resolveMcpServerDefinition(definitions[0], CancellationToken.None)).rejects.toThrow();
401
});
402
});
403
404
describe('authentication change events', () => {
405
test('fires onDidChangeMcpServerDefinitions when token becomes available', async () => {
406
const providerWithoutToken = await createProvider({ hasPermissiveToken: false });
407
const eventPromise = Event.toPromise(providerWithoutToken.onDidChangeMcpServerDefinitions);
408
409
authService.setPermissiveGitHubSession({ accessToken: 'new-token', id: 'new-id', account: { id: 'new-account', label: 'new' }, scopes: [] });
410
411
await eventPromise;
412
});
413
414
test('fires onDidChangeMcpServerDefinitions when token is removed', async () => {
415
const eventPromise = Event.toPromise(provider.onDidChangeMcpServerDefinitions);
416
417
authService.setPermissiveGitHubSession(undefined);
418
419
await eventPromise;
420
});
421
422
test('does not fire when token changes but availability remains the same', async () => {
423
let eventFired = false;
424
const handler = () => {
425
eventFired = true;
426
};
427
const disposable = provider.onDidChangeMcpServerDefinitions(handler);
428
429
// Change the token value but keep it defined
430
authService.setPermissiveGitHubSession({ accessToken: 'different-token', id: 'different-id', account: { id: 'different-account', label: 'different' }, scopes: [] });
431
432
await raceTimeout(Promise.resolve(), 50);
433
434
expect(eventFired).toBe(false);
435
disposable.dispose();
436
});
437
});
438
});
439
440