diff --git a/package-lock.json b/package-lock.json index bf395838ca4ec..1355afa5f5e38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@anthropic-ai/sandbox-runtime": "0.0.42", "@github/copilot": "^1.0.24", "@github/copilot-sdk": "^0.2.2", + "@github/mcp-registry": "^0.1.5", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -1207,6 +1208,27 @@ "copilot-win32-x64": "copilot.exe" } }, + "node_modules/@github/mcp-registry": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@github/mcp-registry/-/mcp-registry-0.1.5.tgz", + "integrity": "sha512-teQo8AUQdJfVDmzIurUZgt0s2zaZnHTxPBufLLXnbXic4qLNrs8RRdR7P1kuyZfXiPurqc0XujzFyeki9rHCPA==", + "license": "MIT", + "dependencies": { + "zod": "~4.3.6" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@github/mcp-registry/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@gulp-sourcemaps/identity-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@gulp-sourcemaps/identity-map/-/identity-map-2.0.1.tgz", diff --git a/package.json b/package.json index 966cff92094a6..5409f61977cd4 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@anthropic-ai/sandbox-runtime": "0.0.42", "@github/copilot": "^1.0.24", "@github/copilot-sdk": "^0.2.2", + "@github/mcp-registry": "^0.1.5", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index 40b2d70a318e9..1f37775a98e7d 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -41,12 +41,35 @@ export interface IEntitlementsData extends ILegacyQuotaSnapshotData { }; } +/** + * An enterprise MCP registry entry from the `copilot/mcp_registry` response + * that signals GitHub-native enterprise allowlist enforcement. + */ +export interface IMcpAllowlistEntry { + /** The enterprise registry base URL (e.g., "https://registry.github.com/mcp") */ + readonly registryUrl: string; + /** The registry access level */ + readonly registryAccess: 'allow_all' | 'registry_only'; + /** The owner (org or enterprise) login */ + readonly ownerLogin: string; + /** The owner (org or enterprise) numeric ID */ + readonly ownerId: number; + /** The owner type (e.g., "Organization") */ + readonly ownerType: string; + /** Parent enterprise login, if the owner is an org under an enterprise */ + readonly parentLogin: string | null; + /** Priority for evaluation ordering */ + readonly priority: number; +} + export interface IPolicyData { readonly mcp?: boolean; readonly chat_preview_features_enabled?: boolean; readonly chat_agent_enabled?: boolean; readonly mcpRegistryUrl?: string; readonly mcpAccess?: 'allow_all' | 'registry_only'; + /** Enterprise MCP allowlist entries discovered from `copilot/mcp_registry`. */ + readonly mcpAllowlistEntries?: readonly IMcpAllowlistEntry[]; } export interface ICopilotTokenInfo { diff --git a/src/vs/platform/mcp/common/mcpAllowListService.ts b/src/vs/platform/mcp/common/mcpAllowListService.ts new file mode 100644 index 0000000000000..f8366f651902a --- /dev/null +++ b/src/vs/platform/mcp/common/mcpAllowListService.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { IMarkdownString } from '../../../base/common/htmlContent.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; + +/** + * State of the enterprise MCP allow list service. + */ +export const enum McpAllowListState { + /** Enterprise allow list enforcement is not applicable (no enterprise entries). */ + NotApplicable, + /** The allow list is currently being fetched. */ + Loading, + /** The allow list has been loaded and is ready for enforcement. */ + Ready, + /** The allow list could not be loaded (network failure, etc.). */ + Unavailable, +} + +/** + * Service that manages enterprise MCP server allow lists. + * + * When a user is in an enterprise with MCP allow list policies, this service + * fetches the allow list from the enterprise registry and gates server launches. + */ +export const IMcpAllowListService = createDecorator('IMcpAllowListService'); +export interface IMcpAllowListService { + readonly _serviceBrand: undefined; + + /** State of the allow list service. */ + readonly state: McpAllowListState; + + /** + * Waits until the allow list is loaded or the service determines that + * enterprise allow list enforcement is not applicable. Returns immediately + * if already resolved. + */ + waitForReady(token?: CancellationToken): Promise; + + /** + * Checks whether a server (identified by its fingerprint) is allowed to run. + * + * @param fingerprint The computed SHA-256 fingerprint of the server's identity. + * @returns `true` if the server is allowed, or an `IMarkdownString` explaining why it was blocked. + */ + isAllowed(fingerprint: string): true | IMarkdownString; +} diff --git a/src/vs/workbench/contrib/git/common/utils.ts b/src/vs/workbench/contrib/git/common/utils.ts index eeb0e75eac56a..40acafb2cbeeb 100644 --- a/src/vs/workbench/contrib/git/common/utils.ts +++ b/src/vs/workbench/contrib/git/common/utils.ts @@ -64,7 +64,7 @@ function getOrderedRemotes(repositoryState: GitRepositoryState): readonly GitRem return Array.from(remotes.values()); } -function parseRemoteUrl(fetchUrl: string): { host: string; rawHost: string; path: string } | undefined { +export function parseRemoteUrl(fetchUrl: string): { host: string; rawHost: string; path: string } | undefined { fetchUrl = fetchUrl.trim(); try { // Normalize git shorthand syntax (git@github.com:user/repo.git) into an explicit ssh:// url diff --git a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts index d1c8e7ea22b28..0ee2cd1e7de02 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts @@ -9,6 +9,7 @@ import { SyncDescriptor } from '../../../../platform/instantiation/common/descri import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import * as jsonContributionRegistry from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; import { mcpAccessConfig, McpAccessValue } from '../../../../platform/mcp/common/mcpManagement.js'; +import { IMcpAllowListService } from '../../../../platform/mcp/common/mcpAllowListService.js'; import { IQuickAccessRegistry, Extensions as QuickAccessExtensions } from '../../../../platform/quickinput/common/quickAccess.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; @@ -49,7 +50,9 @@ import { McpServerEditor } from './mcpServerEditor.js'; import { McpServerEditorInput } from './mcpServerEditorInput.js'; import { McpServersViewsContribution } from './mcpServersView.js'; import { MCPContextsInitialisation, McpWorkbenchService } from './mcpWorkbenchService.js'; +import { McpAllowListService } from '../common/mcpAllowListService.js'; +registerSingleton(IMcpAllowListService, McpAllowListService, InstantiationType.Delayed); registerSingleton(IMcpRegistry, McpRegistry, InstantiationType.Delayed); registerSingleton(IMcpSandboxService, McpSandboxService, InstantiationType.Delayed); registerSingleton(IMcpService, McpService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/mcp/common/mcpAllowListService.ts b/src/vs/workbench/contrib/mcp/common/mcpAllowListService.ts new file mode 100644 index 0000000000000..8f34e65cf48ad --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/mcpAllowListService.ts @@ -0,0 +1,352 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise, disposableTimeout, LazyStatefulPromise, raceCancellation } from '../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { intersection } from '../../../../base/common/collections.js'; +import { IMcpAllowlistEntry } from '../../../../base/common/defaultAccount.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IMcpAllowListService, McpAllowListState } from '../../../../platform/mcp/common/mcpAllowListService.js'; +import { mcpAccessConfig, McpAccessValue } from '../../../../platform/mcp/common/mcpManagement.js'; +import { asJson, IRequestService } from '../../../../platform/request/common/request.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IAuthenticationService } from '../../../services/authentication/common/authentication.js'; +import { IGitService } from '../../git/common/gitService.js'; +import { parseRemoteUrl } from '../../git/common/utils.js'; + +const ALLOWLIST_CACHE_KEY = 'mcp.enterprise.allowlist.cache'; +const ALLOWLIST_REFETCH_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes + +/** + * A list of allowed MCP server fingerprints, or undefined if the allow list is not applicable. + */ +type AllowList = Set | undefined; + +/** + * A query to an enterprise registry endpoint, optionally scoped to a specific repo. + */ +type RegistryQuery = { entry: IMcpAllowlistEntry; repo?: string }; + +/** + * Shape of the enterprise allow list API list/evaluate response. + */ +interface IAllowListApiResponse { + readonly metadata?: { readonly count?: number; readonly nextCursor?: string }; + readonly servers?: ReadonlyArray<{ + readonly server?: { + readonly name?: string; + readonly fingerprints?: Readonly>; + }; + }>; +} + +/** + * Extract all fingerprint values from an API response. + */ +function extractFingerprints(response: IAllowListApiResponse | null): Set { + const result = new Set(); + if (response?.servers) { + for (const server of response.servers) { + if (server.server?.fingerprints) { + for (const fp of Object.values(server.server.fingerprints)) { + if (typeof fp === 'string') { + result.add(fp); + } + } + } + } + } + return result; +} + +export class McpAllowListService extends Disposable implements IMcpAllowListService { + declare _serviceBrand: undefined; + + private readyDeferred = new DeferredPromise(); + private allowList: AllowList; + private fetchCts?: CancellationTokenSource; + private readonly refreshTimer = this._register(new MutableDisposable()); + + private _state = McpAllowListState.Unavailable; + public get state() { + return this._state; + } + private set state(value: McpAllowListState) { + this._state = value; + } + + constructor( + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IAuthenticationService private readonly authenticationService: IAuthenticationService, + @IRequestService private readonly requestService: IRequestService, + @ILogService private readonly logService: ILogService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IStorageService private readonly storageService: IStorageService, + @IGitService private readonly gitService: IGitService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + ) { + super(); + + this._register(this.defaultAccountService.onDidChangePolicyData(() => this.loadPolicy())); + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.loadPolicy())); + void this.loadPolicy(); + } + + /** + * Blocks until the allow list is loaded or determined not applicable. + */ + async waitForReady(token?: CancellationToken): Promise { + if (token) { + await raceCancellation(this.readyDeferred.p, token); + } else { + await this.readyDeferred.p; + } + } + + /** + * Checks whether a server fingerprint is permitted by the enterprise allow list. + */ + isAllowed(fingerprint: string): true | MarkdownString { + switch (this.state) { + case McpAllowListState.NotApplicable: + return true; + + case McpAllowListState.Loading: + return new MarkdownString(localize( + 'mcp.allowlist.loading', + "MCP server access is being verified against your organization's policy. Please try again shortly." + )); + + case McpAllowListState.Unavailable: + if (this.configurationService.getValue(mcpAccessConfig) === McpAccessValue.All) { + return true; + } else { + return new MarkdownString(localize( + 'mcp.allowlist.unavailable', + "Unable to verify MCP server access against your organization's policy. Please check your network connection." + )); + } + + default: + if (this.allowList?.has(fingerprint)) { + return true; + } else { + return new MarkdownString(localize( + 'mcp.allowlist.blocked', + "This MCP server is not permitted by your organization's policy." + )); + } + } + } + + /** + * Loads or refreshes the allow list. On initial/policy load, resets state and + * deferred gate. On background refresh, keeps existing data on failure. + */ + private async loadPolicy(isRefresh = false) { + if (isRefresh && this.state === McpAllowListState.Loading) { + return; + } + + this.fetchCts?.dispose(true); + const cts = this.fetchCts = new CancellationTokenSource(); + + if (!isRefresh) { + this.refreshTimer.clear(); + this.state = McpAllowListState.Loading; + if (this.readyDeferred.isSettled) { + this.readyDeferred.complete(); + this.readyDeferred = new DeferredPromise(); + } + } + + try { + const result = await this.loadMergedAllowList(cts.token); + if (cts.token.isCancellationRequested) { + return; + } + + this.allowList = result; + this.state = result === undefined ? McpAllowListState.NotApplicable : McpAllowListState.Ready; + } catch (error) { + if (cts.token.isCancellationRequested) { + return; + } + + if (isRefresh) { + this.logService.debug('[McpAllowlist] Background refresh failed, keeping existing data:', error); + } else { + this.logService.error('[McpAllowlist] Failed to load allowlist:', error); + this.state = McpAllowListState.Unavailable; + } + } + + if (!isRefresh) { + this.readyDeferred.complete(); + } + + this.refreshTimer.value = disposableTimeout(() => this.loadPolicy(true), ALLOWLIST_REFETCH_INTERVAL_MS); + } + + /** + * Loads fingerprints from cache and (if authToken is specified) by fetching from the registry. + */ + private async loadMergedAllowList(token: CancellationToken): Promise { + const authToken = new LazyStatefulPromise(() => this.getAuthToken(token)); + let result: AllowList = undefined; + let lastError: Error | undefined = undefined; + + for (const { entry, repo } of this.listRegistryQueries()) { + if (token.isCancellationRequested) { + return undefined; + } + + try { + const current = await this.loadAllowList({ entry, repo }, authToken, token); + result = this.mergeAllowLists(result, current); + } catch (error) { + this.logService.debug(`[McpAllowlist] Error loading allowlist for ${entry.ownerLogin}${repo ? `/${repo}` : ''}:`, error); + lastError = error; + } + } + + if (lastError) { + throw lastError; + } + + return result; + } + + /** + * Merges two allowlists with an intersection; if one is undefined, returns the other. + */ + private mergeAllowLists(a: AllowList, b: AllowList): AllowList { + return !a ? b : !b ? a : intersection(a, b); + } + + /** + * Maps workspace repos to their enterprise entries; unmatched entries are queried without repo context. + */ + private * listRegistryQueries(): Iterable { + const entries = this.defaultAccountService.policyData?.mcpAllowlistEntries; + if (!entries || entries.length === 0) { + return; + } + + const entryMap = new Map(entries.map(e => [e.ownerLogin, e])); + const seenRepos = new Set(); + const matchedOwners = new Set(); + + for (const repository of this.gitService.repositories) { + for (const remote of repository.state.get().remotes) { + if (!remote.fetchUrl) { + continue; + } + + const parsed = parseRemoteUrl(remote.fetchUrl); + if (!parsed || !/^(.*\.)?(github|ghe)\.com$/.test(parsed.host)) { + continue; + } + + const match = parsed.path.match(/^\/?([^/]+)\/([^/]+?)(?:\.git\/?)?$/i); + if (!match) { + continue; + } + + const owner = match[1]; + const repo = `${owner}/${match[2]}`; + if (seenRepos.has(repo)) { + continue; + } + + seenRepos.add(repo); + const entry = entryMap.get(owner); + if (entry) { + yield { entry, repo }; + matchedOwners.add(owner); + } + } + } + + // Entries with no matching are queried without repo context + for (const [owner, entry] of entryMap) { + if (!matchedOwners.has(owner)) { + yield { entry }; + } + } + } + + /** + * Loads list of allowed MCP server fingerprints from cache if available, or fetches from the API if not. + */ + private async loadAllowList(query: RegistryQuery, authToken: LazyStatefulPromise, token: CancellationToken): Promise { + const { entry, repo } = query; + const storageKey = `${ALLOWLIST_CACHE_KEY}.${entry.ownerId}${repo ? `.${repo}` : ''}`; + + const cached = this.storageService.getObject<{ value: AllowList }>(storageKey, StorageScope.APPLICATION); + if (cached) { + this.logService.debug(`[McpAllowlist] Loaded allowlist for ${repo ?? ''} from cache`); + return cached.value; + } + + const value = await this.fetchAllowList(query, authToken, token); + this.storageService.store(storageKey, JSON.stringify({ value }), StorageScope.APPLICATION, StorageTarget.MACHINE); + return value; + } + + /** + * Fetches list of allowed MCP server fingerprints for the specified registry query. + */ + private async fetchAllowList(query: RegistryQuery, authToken: LazyStatefulPromise, token: CancellationToken): Promise { + const { entry, repo } = query; + const url = `${entry.registryUrl}/enterprise/v0.1/servers${repo ? `?repo=${encodeURIComponent(repo)}` : ''}`; + + this.logService.debug(`[McpAllowlist] Fetching allowlist from ${url}`); + const response = await this.requestService.request({ + type: 'GET', + url, + headers: { 'Authorization': `Bearer ${await authToken.getPromise()}` }, + callSite: 'mcpAllowlist.fetchFingerprints', + }, token); + + const code = response.res.statusCode; + switch (code) { + case 200: + return extractFingerprints(await asJson(response)); + case 422: + return undefined; + default: + throw new Error(`Unexpected status code ${code} from allowlist API`); + } + } + + /** + * Attempts to retrieve the access token for the signed-in default account. + */ + private async getAuthToken(token: CancellationToken): Promise { + const account = await this.defaultAccountService.getDefaultAccount(); + if (!account) { + throw new Error('No default account'); + } + + if (token.isCancellationRequested) { + throw new Error('Cancelled'); + } + + const sessions = await this.authenticationService.getSessions(account.authenticationProvider.id, undefined, { silent: true }); + const session = sessions.find(o => o.id === account.sessionId); + if (!session?.accessToken) { + throw new Error('No session or access token found for default account'); + } + + return session?.accessToken; + } +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts index d9ff9dad84aa0..32371b03f973d 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts @@ -3,10 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { importAMDNodeModule } from '../../../../amdX.js'; import { assertNever } from '../../../../base/common/assert.js'; +import { raceTimeout } from '../../../../base/common/async.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter } from '../../../../base/common/event.js'; -import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { Lazy } from '../../../../base/common/lazy.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; @@ -20,6 +22,7 @@ import { ExtensionIdentifier } from '../../../../platform/extensions/common/exte import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IMcpAllowListService, McpAllowListState } from '../../../../platform/mcp/common/mcpAllowListService.js'; import { mcpAccessConfig, McpAccessValue } from '../../../../platform/mcp/common/mcpManagement.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; @@ -35,7 +38,7 @@ import { McpRegistryInputStorage } from './mcpRegistryInputStorage.js'; import { IMcpHostDelegate, IMcpRegistry, IMcpResolveConnectionOptions } from './mcpRegistryTypes.js'; import { IMcpSandboxService } from './mcpSandboxService.js'; import { McpServerConnection } from './mcpServerConnection.js'; -import { IMcpServerConnection, LazyCollectionState, McpCollectionDefinition, McpDefinitionReference, McpServerDefinition, McpServerLaunch, McpServerTrust, McpStartServerInteraction, UserInteractionRequiredError } from './mcpTypes.js'; +import { IMcpServerConnection, LazyCollectionState, McpCollectionDefinition, McpDefinitionReference, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust, McpStartServerInteraction, UserInteractionRequiredError } from './mcpTypes.js'; const notTrustedNonce = '__vscode_not_trusted'; @@ -90,6 +93,7 @@ export class McpRegistry extends Disposable implements IMcpRegistry { @IMcpSandboxService private readonly _mcpSandboxService: IMcpSandboxService, @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, + @IMcpAllowListService private readonly _mcpAllowlistService: IMcpAllowListService, ) { super(); this._mcpAccessValue = observableConfigValue(mcpAccessConfig, McpAccessValue.All, configurationService); @@ -502,6 +506,19 @@ export class McpRegistry extends Disposable implements IMcpRegistry { throw new Error('No delegate found that can handle the connection'); } + // Enterprise allowlist gate: wait for allowlist data and check if server is permitted + const allowlistResult = await this._checkAllowlist(definition); + if (allowlistResult !== true) { + if (opts.errorOnUserInteraction) { + throw new UserInteractionRequiredError('allowlist'); + } + this._notificationService.notify({ + severity: Severity.Warning, + message: allowlistResult.value, + }); + return undefined; + } + const trusted = await this._checkTrust(collection, definition, opts); interaction?.participants.set(definition.id, { s: 'resolved' }); if (!trusted) { @@ -562,4 +579,70 @@ export class McpRegistry extends Disposable implements IMcpRegistry { opts.taskManager, ); } + + /** + * Checks the enterprise MCP allowlist. Waits for the allowlist to be ready, + * then computes the server fingerprint and verifies it against the allowlist. + */ + private async _checkAllowlist(definition: McpServerDefinition): Promise { + if (this._mcpAllowlistService.state === McpAllowListState.NotApplicable) { + return true; + } + + if (this._mcpAllowlistService.state === McpAllowListState.Loading) { + await raceTimeout(this._mcpAllowlistService.waitForReady(), 10_000); + } + + const fingerprint = await this._computeServerFingerprint(definition); + if (!fingerprint) { + if (this._mcpAllowlistService.state === McpAllowListState.Ready) { + return new MarkdownString(localize('mcp.allowlist.noFingerprint', "Cannot verify this MCP server against your organization's policy because its identity could not be determined.")); + } else { + return true; + } + } + + return this._mcpAllowlistService.isAllowed(fingerprint); + } + + /** + * Computes a fingerprint for a server definition using @github/mcp-registry SDK types. + * Bridges VS Code's McpServerLaunch to the SDK's ServerIdentity. + */ + private async _computeServerFingerprint(definition: McpServerDefinition): Promise { + const launch = definition.launch; + if (!launch) { + return undefined; + } + + try { + // Dynamic import to avoid loading the SDK when enterprise is not active + const { computeFingerprint, commandToRegistryType } = await importAMDNodeModule('@github/mcp-registry', 'dist/index.js'); + + if (launch.type === McpServerTransportType.Stdio) { + const registryType = commandToRegistryType(launch.command); + if (registryType && launch.args.length > 0) { + return computeFingerprint({ + packages: [{ + registryType, + identifier: launch.args[0], + }], + }); + } + } else if (launch.type === McpServerTransportType.HTTP) { + return computeFingerprint({ + remotes: [{ + url: launch.uri.toString(), + headerNames: launch.headers.length > 0 + ? launch.headers.map(h => h[0]) + : undefined, + }], + }); + } + } catch (error) { + this._logService.debug('[McpRegistry] Failed to compute fingerprint:', error); + } + + return undefined; + } } diff --git a/src/vs/workbench/contrib/mcp/common/mcpService.ts b/src/vs/workbench/contrib/mcp/common/mcpService.ts index 940704772ae02..d4dd867691c51 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpService.ts @@ -10,6 +10,7 @@ import { autorun, IObservable, ISettableObservable, observableValue, transaction import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IMcpAllowListService } from '../../../../platform/mcp/common/mcpAllowListService.js'; import { mcpAutoStartConfig, McpAutoStartValue } from '../../../../platform/mcp/common/mcpManagement.js'; import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; import { EnablementModel, isContributionEnabled } from '../../chat/common/enablement.js'; @@ -41,6 +42,7 @@ export class McpService extends Disposable implements IMcpService { @ILogService private readonly _logService: ILogService, @IConfigurationService private readonly configurationService: IConfigurationService, @IStorageService storageService: IStorageService, + @IMcpAllowListService private readonly _mcpAllowlistService: IMcpAllowListService, ) { super(); @@ -102,6 +104,14 @@ export class McpService extends Disposable implements IMcpService { return; } + // Wait for the enterprise allowlist to be ready before starting servers, + // so we don't attempt to start servers that will be blocked. + await this._mcpAllowlistService.waitForReady(token); + + if (token.isCancellationRequested) { + return; + } + // don't try re-running errored servers or disabled servers const candidates = this.servers.get().filter(s => s.connectionState.get().state !== McpConnectionState.Kind.Error diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts index 722a4ee2fe77f..6ce622bfe3000 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts @@ -18,6 +18,7 @@ import { ServiceCollection } from '../../../../../platform/instantiation/common/ import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogger, ILoggerService, ILogService, NullLogger, NullLogService } from '../../../../../platform/log/common/log.js'; import { mcpAccessConfig, McpAccessValue } from '../../../../../platform/mcp/common/mcpManagement.js'; +import { IMcpAllowListService, McpAllowListState } from '../../../../../platform/mcp/common/mcpAllowListService.js'; import { IMcpSandboxConfiguration } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { TestNotificationService } from '../../../../../platform/notification/test/common/testNotificationService.js'; @@ -208,6 +209,7 @@ suite('Workbench - MCP - Registry', () => { [IDialogService, testDialogService], [IMcpSandboxService, testMcpSandboxService], [IProductService, {}], + [IMcpAllowListService, { state: McpAllowListState.NotApplicable, waitForReady: () => Promise.resolve(), isAllowed: () => true }], ); logger = new NullLogger(); diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpResourceFilesystem.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpResourceFilesystem.test.ts index e546b5f5153ff..dcf77a77376e9 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpResourceFilesystem.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpResourceFilesystem.test.ts @@ -25,6 +25,14 @@ import { McpService } from '../../common/mcpService.js'; import { IMcpService } from '../../common/mcpTypes.js'; import { MCP } from '../../common/modelContextProtocol.js'; import { TestMcpMessageTransport, TestMcpRegistry } from './mcpRegistryTypes.js'; +import { IMcpAllowListService, McpAllowListState } from '../../../../../platform/mcp/common/mcpAllowListService.js'; + +const mcpAllowlistService: IMcpAllowListService = { + _serviceBrand: undefined, + state: McpAllowListState.NotApplicable, + waitForReady: () => Promise.resolve(), + isAllowed: () => true, +}; suite('Workbench - MCP - ResourceFilesystem', () => { @@ -50,7 +58,7 @@ suite('Workbench - MCP - ResourceFilesystem', () => { const registry = new TestMcpRegistry(parentInsta1); const parentInsta2 = ds.add(parentInsta1.createChild(new ServiceCollection([IMcpRegistry, registry]))); - const mcpService = ds.add(new McpService(parentInsta2, registry, new NullLogService(), new TestConfigurationService(), storageService)); + const mcpService = ds.add(new McpService(parentInsta2, registry, new NullLogService(), new TestConfigurationService(), storageService, mcpAllowlistService)); mcpService.updateCollectedServers(); const instaService = ds.add(parentInsta2.createChild(new ServiceCollection( diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 26ae17e3109f4..ac5650c1b6d9b 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -6,7 +6,7 @@ import { distinct } from '../../../../base/common/arrays.js'; import { Barrier, RunOnceScheduler, ThrottledDelayer, timeout } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { ICopilotTokenInfo, IDefaultAccount, IDefaultAccountAuthenticationProvider, IEntitlementsData, IPolicyData } from '../../../../base/common/defaultAccount.js'; +import { ICopilotTokenInfo, IDefaultAccount, IDefaultAccountAuthenticationProvider, IEntitlementsData, IMcpAllowlistEntry, IPolicyData } from '../../../../base/common/defaultAccount.js'; import { getErrorMessage } from '../../../../base/common/errors.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; @@ -79,6 +79,11 @@ interface IMcpRegistryProvider { readonly parent_login: string | null; readonly priority: number; }; + readonly capabilities?: { + readonly registry_type: string; + readonly required_auth?: readonly string[]; + readonly required_context?: readonly string[]; + }; } interface IMcpRegistryResponse { @@ -552,9 +557,32 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid mcpRegistryDataFetchedAt = mcpRegistryResult?.fetchedAt; policyData.mcpRegistryUrl = mcpRegistryResult?.data?.url; policyData.mcpAccess = mcpRegistryResult?.data?.registry_access; + + // Extract enterprise allowlist entries from all registries + const allRegistries = mcpRegistryResult?.data?.allRegistries; + if (allRegistries && allRegistries.length > 0) { + const allowlistEntries: IMcpAllowlistEntry[] = []; + for (const registry of allRegistries) { + if (registry.capabilities?.registry_type === 'github_enterprise' && registry.owner) { + allowlistEntries.push({ + registryUrl: registry.url, + registryAccess: registry.registry_access, + ownerLogin: registry.owner.login, + ownerId: registry.owner.id, + ownerType: registry.owner.type, + parentLogin: registry.owner.parent_login ?? null, + priority: registry.owner.priority, + }); + } + } + policyData.mcpAllowlistEntries = allowlistEntries.length > 0 ? allowlistEntries : undefined; + } + // When using cached registry data, allRegistries is not available — + // preserve the existing mcpAllowlistEntries from the cached policyData. } else { policyData.mcpRegistryUrl = undefined; policyData.mcpAccess = undefined; + policyData.mcpAllowlistEntries = undefined; } } @@ -723,17 +751,20 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return { data: undefined, fetchedAt: Date.now() }; } - private async getMcpRegistryProvider(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise<{ data: IMcpRegistryProvider | null; fetchedAt: number } | undefined> { + private async getMcpRegistryProvider(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise<{ data: (IMcpRegistryProvider & { allRegistries?: ReadonlyArray }) | null; fetchedAt: number } | undefined> { if (accountPolicyData?.mcpRegistryDataFetchedAt && !this.isDataStale(accountPolicyData.mcpRegistryDataFetchedAt)) { this.logService.debug('[DefaultAccount] Using last fetched MCP registry data'); - const data = accountPolicyData.policyData.mcpRegistryUrl && accountPolicyData.policyData.mcpAccess ? { url: accountPolicyData.policyData.mcpRegistryUrl, registry_access: accountPolicyData.policyData.mcpAccess } : null; + const policyData = accountPolicyData.policyData; + const data: IMcpRegistryProvider | null = policyData.mcpRegistryUrl && policyData.mcpAccess + ? { url: policyData.mcpRegistryUrl, registry_access: policyData.mcpAccess } + : null; return { data, fetchedAt: accountPolicyData.mcpRegistryDataFetchedAt }; } const data = await this.requestMcpRegistryProvider(sessions); return !isUndefined(data) ? { data, fetchedAt: Date.now() } : undefined; } - private async requestMcpRegistryProvider(sessions: AuthenticationSession[]): Promise { + private async requestMcpRegistryProvider(sessions: AuthenticationSession[]): Promise<(IMcpRegistryProvider & { allRegistries?: ReadonlyArray }) | null | undefined> { const mcpRegistryDataUrl = this.getMcpRegistryDataUrl(); if (!mcpRegistryDataUrl) { this.logService.debug('[DefaultAccount] No MCP registry data URL found'); @@ -759,7 +790,12 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid const data = await asJson(response); if (data) { this.logService.debug('Fetched MCP registry providers', data.mcp_registries); - return data.mcp_registries[0] ?? null; + const primary = data.mcp_registries[0]; + if (!primary) { + return null; + } + // Return primary registry with all registries attached for enterprise allowlist extraction + return { ...primary, allRegistries: data.mcp_registries }; } this.logService.debug('No MCP registry providers content found in response'); return null;