Path: blob/main/src/vs/workbench/services/authentication/browser/authenticationMcpService.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Disposable, DisposableStore, dispose, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';6import { scopesMatch } from '../../../../base/common/oauth.js';7import * as nls from '../../../../nls.js';8import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js';9import { CommandsRegistry } from '../../../../platform/commands/common/commands.js';10import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';11import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';12import { Severity } from '../../../../platform/notification/common/notification.js';13import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js';14import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';15import { IActivityService, NumberBadge } from '../../activity/common/activity.js';16import { IAuthenticationMcpAccessService } from './authenticationMcpAccessService.js';17import { IAuthenticationMcpUsageService } from './authenticationMcpUsageService.js';18import { AuthenticationSession, IAuthenticationProvider, IAuthenticationService, AuthenticationSessionAccount } from '../common/authentication.js';19import { Emitter, Event } from '../../../../base/common/event.js';20import { IProductService } from '../../../../platform/product/common/productService.js';21import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';2223// OAuth2 spec prohibits space in a scope, so use that to join them.24const SCOPESLIST_SEPARATOR = ' ';2526interface SessionRequest {27disposables: IDisposable[];28requestingMcpServerIds: string[];29}3031interface SessionRequestInfo {32[scopesList: string]: SessionRequest;33}3435// TODO: Move this into MainThreadAuthentication36export const IAuthenticationMcpService = createDecorator<IAuthenticationMcpService>('IAuthenticationMcpService');37export interface IAuthenticationMcpService {38readonly _serviceBrand: undefined;3940/**41* Fires when an account preference for a specific provider has changed for the specified MCP servers. Does not fire when:42* * An account preference is removed43* * A session preference is changed (because it's deprecated)44* * A session preference is removed (because it's deprecated)45*/46onDidChangeAccountPreference: Event<{ mcpServerIds: string[]; providerId: string }>;47/**48* Returns the accountName (also known as account.label) to pair with `IAuthenticationMCPServerAccessService` to get the account preference49* @param providerId The authentication provider id50* @param mcpServerId The MCP server id to get the preference for51* @returns The accountName of the preference, or undefined if there is no preference set52*/53getAccountPreference(mcpServerId: string, providerId: string): string | undefined;54/**55* Sets the account preference for the given provider and MCP server56* @param providerId The authentication provider id57* @param mcpServerId The MCP server id to set the preference for58* @param account The account to set the preference to59*/60updateAccountPreference(mcpServerId: string, providerId: string, account: AuthenticationSessionAccount): void;61/**62* Removes the account preference for the given provider and MCP server63* @param providerId The authentication provider id64* @param mcpServerId The MCP server id to remove the preference for65*/66removeAccountPreference(mcpServerId: string, providerId: string): void;67/**68* @deprecated Sets the session preference for the given provider and MCP server69* @param providerId70* @param mcpServerId71* @param session72*/73updateSessionPreference(providerId: string, mcpServerId: string, session: AuthenticationSession): void;74/**75* @deprecated Gets the session preference for the given provider and MCP server76* @param providerId77* @param mcpServerId78* @param scopes79*/80getSessionPreference(providerId: string, mcpServerId: string, scopes: string[]): string | undefined;81/**82* @deprecated Removes the session preference for the given provider and MCP server83* @param providerId84* @param mcpServerId85* @param scopes86*/87removeSessionPreference(providerId: string, mcpServerId: string, scopes: string[]): void;88selectSession(providerId: string, mcpServerId: string, mcpServerName: string, scopes: string[], possibleSessions: readonly AuthenticationSession[]): Promise<AuthenticationSession>;89requestSessionAccess(providerId: string, mcpServerId: string, mcpServerName: string, scopes: string[], possibleSessions: readonly AuthenticationSession[]): void;90requestNewSession(providerId: string, scopes: string[], mcpServerId: string, mcpServerName: string): Promise<void>;91}9293// TODO@TylerLeonhardt: This should all go in MainThreadAuthentication94export class AuthenticationMcpService extends Disposable implements IAuthenticationMcpService {95declare readonly _serviceBrand: undefined;96private _signInRequestItems = new Map<string, SessionRequestInfo>();97private _sessionAccessRequestItems = new Map<string, { [mcpServerId: string]: { disposables: IDisposable[]; possibleSessions: AuthenticationSession[] } }>();98private readonly _accountBadgeDisposable = this._register(new MutableDisposable());99100private _onDidAccountPreferenceChange: Emitter<{ providerId: string; mcpServerIds: string[] }> = this._register(new Emitter<{ providerId: string; mcpServerIds: string[] }>());101readonly onDidChangeAccountPreference = this._onDidAccountPreferenceChange.event;102103private _inheritAuthAccountPreferenceParentToChildren: Record<string, string[]>;104private _inheritAuthAccountPreferenceChildToParent: { [mcpServerId: string]: string };105106constructor(107@IActivityService private readonly activityService: IActivityService,108@IStorageService private readonly storageService: IStorageService,109@IDialogService private readonly dialogService: IDialogService,110@IQuickInputService private readonly quickInputService: IQuickInputService,111@IProductService private readonly _productService: IProductService,112@IAuthenticationService private readonly _authenticationService: IAuthenticationService,113@IAuthenticationMcpUsageService private readonly _authenticationUsageService: IAuthenticationMcpUsageService,114@IAuthenticationMcpAccessService private readonly _authenticationAccessService: IAuthenticationMcpAccessService115) {116super();117this._inheritAuthAccountPreferenceParentToChildren = this._productService.inheritAuthAccountPreference || {};118this._inheritAuthAccountPreferenceChildToParent = Object.entries(this._inheritAuthAccountPreferenceParentToChildren).reduce<{ [mcpServerId: string]: string }>((acc, [parent, children]) => {119children.forEach((child: string) => {120acc[child] = parent;121});122return acc;123}, {});124this.registerListeners();125}126127private registerListeners() {128this._register(this._authenticationService.onDidChangeSessions(async e => {129if (e.event.added?.length) {130await this.updateNewSessionRequests(e.providerId, e.event.added);131}132if (e.event.removed?.length) {133await this.updateAccessRequests(e.providerId, e.event.removed);134}135this.updateBadgeCount();136}));137138this._register(this._authenticationService.onDidUnregisterAuthenticationProvider(e => {139const accessRequests = this._sessionAccessRequestItems.get(e.id) || {};140Object.keys(accessRequests).forEach(mcpServerId => {141this.removeAccessRequest(e.id, mcpServerId);142});143}));144}145146private async updateNewSessionRequests(providerId: string, addedSessions: readonly AuthenticationSession[]): Promise<void> {147const existingRequestsForProvider = this._signInRequestItems.get(providerId);148if (!existingRequestsForProvider) {149return;150}151152Object.keys(existingRequestsForProvider).forEach(requestedScopes => {153// Parse the requested scopes from the stored key154const requestedScopesArray = requestedScopes.split(SCOPESLIST_SEPARATOR);155156// Check if any added session has matching scopes (order-independent)157if (addedSessions.some(session => scopesMatch(session.scopes, requestedScopesArray))) {158const sessionRequest = existingRequestsForProvider[requestedScopes];159sessionRequest?.disposables.forEach(item => item.dispose());160161delete existingRequestsForProvider[requestedScopes];162if (Object.keys(existingRequestsForProvider).length === 0) {163this._signInRequestItems.delete(providerId);164} else {165this._signInRequestItems.set(providerId, existingRequestsForProvider);166}167}168});169}170171private async updateAccessRequests(providerId: string, removedSessions: readonly AuthenticationSession[]) {172const providerRequests = this._sessionAccessRequestItems.get(providerId);173if (providerRequests) {174Object.keys(providerRequests).forEach(mcpServerId => {175removedSessions.forEach(removed => {176const indexOfSession = providerRequests[mcpServerId].possibleSessions.findIndex(session => session.id === removed.id);177if (indexOfSession) {178providerRequests[mcpServerId].possibleSessions.splice(indexOfSession, 1);179}180});181182if (!providerRequests[mcpServerId].possibleSessions.length) {183this.removeAccessRequest(providerId, mcpServerId);184}185});186}187}188189private updateBadgeCount(): void {190this._accountBadgeDisposable.clear();191192let numberOfRequests = 0;193this._signInRequestItems.forEach(providerRequests => {194Object.keys(providerRequests).forEach(request => {195numberOfRequests += providerRequests[request].requestingMcpServerIds.length;196});197});198199this._sessionAccessRequestItems.forEach(accessRequest => {200numberOfRequests += Object.keys(accessRequest).length;201});202203if (numberOfRequests > 0) {204const badge = new NumberBadge(numberOfRequests, () => nls.localize('sign in', "Sign in requested"));205this._accountBadgeDisposable.value = this.activityService.showAccountsActivity({ badge });206}207}208209private removeAccessRequest(providerId: string, mcpServerId: string): void {210const providerRequests = this._sessionAccessRequestItems.get(providerId) || {};211if (providerRequests[mcpServerId]) {212dispose(providerRequests[mcpServerId].disposables);213delete providerRequests[mcpServerId];214this.updateBadgeCount();215}216}217218//#region Account/Session Preference219220updateAccountPreference(mcpServerId: string, providerId: string, account: AuthenticationSessionAccount): void {221const parentMcpServerId = this._inheritAuthAccountPreferenceChildToParent[mcpServerId] ?? mcpServerId;222const key = this._getKey(parentMcpServerId, providerId);223224// Store the preference in the workspace and application storage. This allows new workspaces to225// have a preference set already to limit the number of prompts that are shown... but also allows226// a specific workspace to override the global preference.227this.storageService.store(key, account.label, StorageScope.WORKSPACE, StorageTarget.MACHINE);228this.storageService.store(key, account.label, StorageScope.APPLICATION, StorageTarget.MACHINE);229230const childrenMcpServers = this._inheritAuthAccountPreferenceParentToChildren[parentMcpServerId];231const mcpServerIds = childrenMcpServers ? [parentMcpServerId, ...childrenMcpServers] : [parentMcpServerId];232this._onDidAccountPreferenceChange.fire({ mcpServerIds, providerId });233}234235getAccountPreference(mcpServerId: string, providerId: string): string | undefined {236const key = this._getKey(this._inheritAuthAccountPreferenceChildToParent[mcpServerId] ?? mcpServerId, providerId);237238// If a preference is set in the workspace, use that. Otherwise, use the global preference.239return this.storageService.get(key, StorageScope.WORKSPACE) ?? this.storageService.get(key, StorageScope.APPLICATION);240}241242removeAccountPreference(mcpServerId: string, providerId: string): void {243const key = this._getKey(this._inheritAuthAccountPreferenceChildToParent[mcpServerId] ?? mcpServerId, providerId);244245// This won't affect any other workspaces that have a preference set, but it will remove the preference246// for this workspace and the global preference. This is only paired with a call to updateSessionPreference...247// so we really don't _need_ to remove them as they are about to be overridden anyway... but it's more correct248// to remove them first... and in case this gets called from somewhere else in the future.249this.storageService.remove(key, StorageScope.WORKSPACE);250this.storageService.remove(key, StorageScope.APPLICATION);251}252253private _getKey(mcpServerId: string, providerId: string): string {254return `${mcpServerId}-${providerId}`;255}256257// TODO@TylerLeonhardt: Remove all of this after a couple iterations258259updateSessionPreference(providerId: string, mcpServerId: string, session: AuthenticationSession): void {260// The 3 parts of this key are important:261// * MCP server id: The MCP server that has a preference262// * Provider id: The provider that the preference is for263// * The scopes: The subset of sessions that the preference applies to264const key = `${mcpServerId}-${providerId}-${session.scopes.join(SCOPESLIST_SEPARATOR)}`;265266// Store the preference in the workspace and application storage. This allows new workspaces to267// have a preference set already to limit the number of prompts that are shown... but also allows268// a specific workspace to override the global preference.269this.storageService.store(key, session.id, StorageScope.WORKSPACE, StorageTarget.MACHINE);270this.storageService.store(key, session.id, StorageScope.APPLICATION, StorageTarget.MACHINE);271}272273getSessionPreference(providerId: string, mcpServerId: string, scopes: string[]): string | undefined {274// The 3 parts of this key are important:275// * MCP server id: The MCP server that has a preference276// * Provider id: The provider that the preference is for277// * The scopes: The subset of sessions that the preference applies to278const key = `${mcpServerId}-${providerId}-${scopes.join(SCOPESLIST_SEPARATOR)}`;279280// If a preference is set in the workspace, use that. Otherwise, use the global preference.281return this.storageService.get(key, StorageScope.WORKSPACE) ?? this.storageService.get(key, StorageScope.APPLICATION);282}283284removeSessionPreference(providerId: string, mcpServerId: string, scopes: string[]): void {285// The 3 parts of this key are important:286// * MCP server id: The MCP server that has a preference287// * Provider id: The provider that the preference is for288// * The scopes: The subset of sessions that the preference applies to289const key = `${mcpServerId}-${providerId}-${scopes.join(SCOPESLIST_SEPARATOR)}`;290291// This won't affect any other workspaces that have a preference set, but it will remove the preference292// for this workspace and the global preference. This is only paired with a call to updateSessionPreference...293// so we really don't _need_ to remove them as they are about to be overridden anyway... but it's more correct294// to remove them first... and in case this gets called from somewhere else in the future.295this.storageService.remove(key, StorageScope.WORKSPACE);296this.storageService.remove(key, StorageScope.APPLICATION);297}298299private _updateAccountAndSessionPreferences(providerId: string, mcpServerId: string, session: AuthenticationSession): void {300this.updateAccountPreference(mcpServerId, providerId, session.account);301this.updateSessionPreference(providerId, mcpServerId, session);302}303304//#endregion305306private async showGetSessionPrompt(provider: IAuthenticationProvider, accountName: string, mcpServerId: string, mcpServerName: string): Promise<boolean> {307enum SessionPromptChoice {308Allow = 0,309Deny = 1,310Cancel = 2311}312const { result } = await this.dialogService.prompt<SessionPromptChoice>({313type: Severity.Info,314message: nls.localize('confirmAuthenticationAccess', "The MCP server '{0}' wants to access the {1} account '{2}'.", mcpServerName, provider.label, accountName),315buttons: [316{317label: nls.localize({ key: 'allow', comment: ['&& denotes a mnemonic'] }, "&&Allow"),318run: () => SessionPromptChoice.Allow319},320{321label: nls.localize({ key: 'deny', comment: ['&& denotes a mnemonic'] }, "&&Deny"),322run: () => SessionPromptChoice.Deny323}324],325cancelButton: {326run: () => SessionPromptChoice.Cancel327}328});329330if (result !== SessionPromptChoice.Cancel) {331this._authenticationAccessService.updateAllowedMcpServers(provider.id, accountName, [{ id: mcpServerId, name: mcpServerName, allowed: result === SessionPromptChoice.Allow }]);332this.removeAccessRequest(provider.id, mcpServerId);333}334335return result === SessionPromptChoice.Allow;336}337338/**339* This function should be used only when there are sessions to disambiguate.340*/341async selectSession(providerId: string, mcpServerId: string, mcpServerName: string, scopes: string[], availableSessions: AuthenticationSession[]): Promise<AuthenticationSession> {342const allAccounts = await this._authenticationService.getAccounts(providerId);343if (!allAccounts.length) {344throw new Error('No accounts available');345}346const disposables = new DisposableStore();347const quickPick = disposables.add(this.quickInputService.createQuickPick<{ label: string; session?: AuthenticationSession; account?: AuthenticationSessionAccount }>());348quickPick.ignoreFocusOut = true;349const accountsWithSessions = new Set<string>();350const items: { label: string; session?: AuthenticationSession; account?: AuthenticationSessionAccount }[] = availableSessions351// Only grab the first account352.filter(session => !accountsWithSessions.has(session.account.label) && accountsWithSessions.add(session.account.label))353.map(session => {354return {355label: session.account.label,356session: session357};358});359360// Add the additional accounts that have been logged into the provider but are361// don't have a session yet.362allAccounts.forEach(account => {363if (!accountsWithSessions.has(account.label)) {364items.push({ label: account.label, account });365}366});367items.push({ label: nls.localize('useOtherAccount', "Sign in to another account") });368quickPick.items = items;369quickPick.title = nls.localize(370{371key: 'selectAccount',372comment: ['The placeholder {0} is the name of a MCP server. {1} is the name of the type of account, such as Microsoft or GitHub.']373},374"The MCP server '{0}' wants to access a {1} account",375mcpServerName,376this._authenticationService.getProvider(providerId).label377);378quickPick.placeholder = nls.localize('getSessionPlateholder', "Select an account for '{0}' to use or Esc to cancel", mcpServerName);379380return await new Promise((resolve, reject) => {381disposables.add(quickPick.onDidAccept(async _ => {382quickPick.dispose();383let session = quickPick.selectedItems[0].session;384if (!session) {385const account = quickPick.selectedItems[0].account;386try {387session = await this._authenticationService.createSession(providerId, scopes, { account });388} catch (e) {389reject(e);390return;391}392}393const accountName = session.account.label;394395this._authenticationAccessService.updateAllowedMcpServers(providerId, accountName, [{ id: mcpServerId, name: mcpServerName, allowed: true }]);396this._updateAccountAndSessionPreferences(providerId, mcpServerId, session);397this.removeAccessRequest(providerId, mcpServerId);398399resolve(session);400}));401402disposables.add(quickPick.onDidHide(_ => {403if (!quickPick.selectedItems[0]) {404reject('User did not consent to account access');405}406disposables.dispose();407}));408409quickPick.show();410});411}412413private async completeSessionAccessRequest(provider: IAuthenticationProvider, mcpServerId: string, mcpServerName: string, scopes: string[]): Promise<void> {414const providerRequests = this._sessionAccessRequestItems.get(provider.id) || {};415const existingRequest = providerRequests[mcpServerId];416if (!existingRequest) {417return;418}419420if (!provider) {421return;422}423const possibleSessions = existingRequest.possibleSessions;424425let session: AuthenticationSession | undefined;426if (provider.supportsMultipleAccounts) {427try {428session = await this.selectSession(provider.id, mcpServerId, mcpServerName, scopes, possibleSessions);429} catch (_) {430// ignore cancel431}432} else {433const approved = await this.showGetSessionPrompt(provider, possibleSessions[0].account.label, mcpServerId, mcpServerName);434if (approved) {435session = possibleSessions[0];436}437}438439if (session) {440this._authenticationUsageService.addAccountUsage(provider.id, session.account.label, session.scopes, mcpServerId, mcpServerName);441}442}443444requestSessionAccess(providerId: string, mcpServerId: string, mcpServerName: string, scopes: string[], possibleSessions: AuthenticationSession[]): void {445const providerRequests = this._sessionAccessRequestItems.get(providerId) || {};446const hasExistingRequest = providerRequests[mcpServerId];447if (hasExistingRequest) {448return;449}450451const provider = this._authenticationService.getProvider(providerId);452const menuItem = MenuRegistry.appendMenuItem(MenuId.AccountsContext, {453group: '3_accessRequests',454command: {455id: `${providerId}${mcpServerId}Access`,456title: nls.localize({457key: 'accessRequest',458comment: [`The placeholder {0} will be replaced with an authentication provider''s label. {1} will be replaced with a MCP server name. (1) is to indicate that this menu item contributes to a badge count`]459},460"Grant access to {0} for {1}... (1)",461provider.label,462mcpServerName)463}464});465466const accessCommand = CommandsRegistry.registerCommand({467id: `${providerId}${mcpServerId}Access`,468handler: async (accessor) => {469this.completeSessionAccessRequest(provider, mcpServerId, mcpServerName, scopes);470}471});472473providerRequests[mcpServerId] = { possibleSessions, disposables: [menuItem, accessCommand] };474this._sessionAccessRequestItems.set(providerId, providerRequests);475this.updateBadgeCount();476}477478async requestNewSession(providerId: string, scopes: string[], mcpServerId: string, mcpServerName: string): Promise<void> {479if (!this._authenticationService.isAuthenticationProviderRegistered(providerId)) {480// Activate has already been called for the authentication provider, but it cannot block on registering itself481// since this is sync and returns a disposable. So, wait for registration event to fire that indicates the482// provider is now in the map.483await new Promise<void>((resolve, _) => {484const dispose = this._authenticationService.onDidRegisterAuthenticationProvider(e => {485if (e.id === providerId) {486dispose.dispose();487resolve();488}489});490});491}492493let provider: IAuthenticationProvider;494try {495provider = this._authenticationService.getProvider(providerId);496} catch (_e) {497return;498}499500const providerRequests = this._signInRequestItems.get(providerId);501const scopesList = scopes.join(SCOPESLIST_SEPARATOR);502const mcpServerHasExistingRequest = providerRequests503&& providerRequests[scopesList]504&& providerRequests[scopesList].requestingMcpServerIds.includes(mcpServerId);505506if (mcpServerHasExistingRequest) {507return;508}509510// Construct a commandId that won't clash with others generated here, nor likely with an MCP server's command511const commandId = `${providerId}:${mcpServerId}:signIn${Object.keys(providerRequests || []).length}`;512const menuItem = MenuRegistry.appendMenuItem(MenuId.AccountsContext, {513group: '2_signInRequests',514command: {515id: commandId,516title: nls.localize({517key: 'signInRequest',518comment: [`The placeholder {0} will be replaced with an authentication provider's label. {1} will be replaced with a MCP server name. (1) is to indicate that this menu item contributes to a badge count.`]519},520"Sign in with {0} to use {1} (1)",521provider.label,522mcpServerName)523}524});525526const signInCommand = CommandsRegistry.registerCommand({527id: commandId,528handler: async (accessor) => {529const authenticationService = accessor.get(IAuthenticationService);530const session = await authenticationService.createSession(providerId, scopes);531532this._authenticationAccessService.updateAllowedMcpServers(providerId, session.account.label, [{ id: mcpServerId, name: mcpServerName, allowed: true }]);533this._updateAccountAndSessionPreferences(providerId, mcpServerId, session);534}535});536537538if (providerRequests) {539const existingRequest = providerRequests[scopesList] || { disposables: [], requestingMcpServerIds: [] };540541providerRequests[scopesList] = {542disposables: [...existingRequest.disposables, menuItem, signInCommand],543requestingMcpServerIds: [...existingRequest.requestingMcpServerIds, mcpServerId]544};545this._signInRequestItems.set(providerId, providerRequests);546} else {547this._signInRequestItems.set(providerId, {548[scopesList]: {549disposables: [menuItem, signInCommand],550requestingMcpServerIds: [mcpServerId]551}552});553}554555this.updateBadgeCount();556}557}558559registerSingleton(IAuthenticationMcpService, AuthenticationMcpService, InstantiationType.Delayed);560561562