Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/vs/workbench/api/browser/mainThreadMcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { ExtensionHostKind, extensionHostKindToString } from '../../services/ext
import { IExtensionService } from '../../services/extensions/common/extensions.js';
import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js';
import { Proxied } from '../../services/extensions/common/proxyIdentifier.js';
import { ExtHostContext, ExtHostMcpShape, IMcpAuthenticationDetails, IMcpAuthenticationOptions, IMcpAuthSetupTelemetry, MainContext, MainThreadMcpShape } from '../common/extHost.protocol.js';
import { ExtHostContext, ExtHostMcpShape, IMcpAuthenticationDetails, IMcpAuthenticationOptions, IAuthMetadataSource, MainContext, MainThreadMcpShape } from '../common/extHost.protocol.js';

@extHostNamedCustomer(MainContext.MainThreadMcp)
export class MainThreadMcp extends Disposable implements MainThreadMcpShape {
Expand Down Expand Up @@ -357,14 +357,14 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape {
}
}

$logMcpAuthSetup(data: IMcpAuthSetupTelemetry): void {
$logMcpAuthSetup(data: IAuthMetadataSource): void {
type McpAuthSetupClassification = {
owner: 'TylerLeonhardt';
comment: 'Tracks how MCP OAuth authentication setup was discovered and configured';
resourceMetadataSource: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How resource metadata was discovered (header, wellKnown, or none)' };
serverMetadataSource: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How authorization server metadata was discovered (resourceMetadata, wellKnown, or default)' };
};
this._telemetryService.publicLog2<IMcpAuthSetupTelemetry, McpAuthSetupClassification>('mcp/authSetup', data);
this._telemetryService.publicLog2<IAuthMetadataSource, McpAuthSetupClassification>('mcp/authSetup', data);
}

private async loginPrompt(mcpLabel: string, providerLabel: string, recreatingSession: boolean): Promise<boolean> {
Expand Down
12 changes: 6 additions & 6 deletions src/vs/workbench/api/common/extHost.protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3156,21 +3156,21 @@ export interface IMcpAuthenticationOptions {
forceNewRegistration?: boolean;
}

export const enum McpAuthResourceMetadataSource {
export const enum IAuthResourceMetadataSource {
Header = 'header',
WellKnown = 'wellKnown',
None = 'none',
}

export const enum McpAuthServerMetadataSource {
export const enum IAuthServerMetadataSource {
ResourceMetadata = 'resourceMetadata',
WellKnown = 'wellKnown',
Default = 'default',
}
Comment on lines +3159 to 3169
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The enum names IAuthResourceMetadataSource and IAuthServerMetadataSource use the 'I' prefix which is conventionally reserved for interfaces in TypeScript. While there are some precedents in this file (ISuggestDataDtoField, ISuggestResultDtoField), most enums in the codebase don't follow this pattern (e.g., TabInputKind, TabModelOperationKind, ExtHostTestingResource). Consider removing the 'I' prefix and naming these enums AuthResourceMetadataSource and AuthServerMetadataSource to align with standard TypeScript naming conventions.

See below for a potential fix:

export const enum AuthResourceMetadataSource {
	Header = 'header',
	WellKnown = 'wellKnown',
	None = 'none',
}

export const enum AuthServerMetadataSource {
	ResourceMetadata = 'resourceMetadata',
	WellKnown = 'wellKnown',
	Default = 'default',
}

export interface IAuthMetadataSource {
	resourceMetadataSource: AuthResourceMetadataSource;
	serverMetadataSource: AuthServerMetadataSource;

Copilot uses AI. Check for mistakes.

export interface IMcpAuthSetupTelemetry {
resourceMetadataSource: McpAuthResourceMetadataSource;
serverMetadataSource: McpAuthServerMetadataSource;
export interface IAuthMetadataSource {
resourceMetadataSource: IAuthResourceMetadataSource;
serverMetadataSource: IAuthServerMetadataSource;
}

export interface MainThreadMcpShape {
Expand All @@ -3181,7 +3181,7 @@ export interface MainThreadMcpShape {
$deleteMcpCollection(collectionId: string): void;
$getTokenFromServerMetadata(id: number, authDetails: IMcpAuthenticationDetails, options?: IMcpAuthenticationOptions): Promise<string | undefined>;
$getTokenForProviderId(id: number, providerId: string, scopes: string[], options?: IMcpAuthenticationOptions): Promise<string | undefined>;
$logMcpAuthSetup(data: IMcpAuthSetupTelemetry): void;
$logMcpAuthSetup(data: IAuthMetadataSource): void;
}

export interface MainThreadDataChannelsShape extends IDisposable {
Expand Down
114 changes: 56 additions & 58 deletions src/vs/workbench/api/common/extHostMcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { StorageScope } from '../../../platform/storage/common/storage.js';
import { extensionPrefixedIdentifier, McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerStaticMetadata, McpServerStaticToolAvailability, McpServerTransportHTTP, McpServerTransportType, UserInteractionRequiredError } from '../../contrib/mcp/common/mcpTypes.js';
import { MCP } from '../../contrib/mcp/common/modelContextProtocol.js';
import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js';
import { ExtHostMcpShape, IMcpAuthenticationDetails, IMcpAuthSetupTelemetry, IStartMcpOptions, MainContext, MainThreadMcpShape, McpAuthResourceMetadataSource, McpAuthServerMetadataSource } from './extHost.protocol.js';
import { ExtHostMcpShape, IMcpAuthenticationDetails, IAuthMetadataSource, IStartMcpOptions, MainContext, MainThreadMcpShape, IAuthResourceMetadataSource, IAuthServerMetadataSource } from './extHost.protocol.js';
import { IExtHostInitDataService } from './extHostInitDataService.js';
import { IExtHostRpcService } from './extHostRpcService.js';
import * as Convert from './extHostTypeConverters.js';
Expand Down Expand Up @@ -703,8 +703,11 @@ export class McpHTTPHandle extends Disposable {
let res = await doFetch();
if (isAuthStatusCode(res.status)) {
if (!this._authMetadata) {
this._authMetadata = await createAuthMetadata(mcpUrl, res, {
launchHeaders: this._launch.headers,
this._authMetadata = await createAuthMetadata(mcpUrl, res.headers, {
sameOriginHeaders: {
...Object.fromEntries(this._launch.headers),
'MCP-Protocol-Version': MCP.LATEST_PROTOCOL_VERSION
},
fetch: (url, init) => this._fetch(url, init as MinimalRequestInit),
log: (level, message) => this._log(level, message)
});
Expand All @@ -717,7 +720,7 @@ export class McpHTTPHandle extends Disposable {
}
} else {
// We have auth metadata, but got an auth error. Check if the scopes changed.
if (this._authMetadata.update(res)) {
if (this._authMetadata.update(res.headers)) {
await this._addAuthHeader(headers);
if (headers['Authorization']) {
// Update the headers in the init object
Expand Down Expand Up @@ -848,14 +851,14 @@ export interface IAuthMetadata {
readonly resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined;
readonly scopes: string[] | undefined;
/** Telemetry data about how auth metadata was discovered */
readonly telemetry: IMcpAuthSetupTelemetry;
readonly telemetry: IAuthMetadataSource;

/**
* Updates the scopes based on the WWW-Authenticate header in the response.
* @param response The HTTP response containing potential scope challenges
Comment on lines 857 to 858
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc comment parameter description still refers to "response" but the parameter has been renamed to "responseHeaders". The description should be updated to reflect this change.

This issue also appears in the following locations of the same file:

  • line 937
Suggested change
* Updates the scopes based on the WWW-Authenticate header in the response.
* @param response The HTTP response containing potential scope challenges
* Updates the scopes based on the WWW-Authenticate header in the response headers.
* @param responseHeaders The HTTP response headers containing potential scope challenges

Copilot uses AI. Check for mistakes.
* @returns true if scopes were updated, false otherwise
*/
update(response: CommonResponse): boolean;
update(responseHeaders: Headers): boolean;
}

/**
Expand All @@ -870,7 +873,7 @@ class AuthMetadata implements IAuthMetadata {
public readonly serverMetadata: IAuthorizationServerMetadata,
public readonly resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined,
scopes: string[] | undefined,
public readonly telemetry: IMcpAuthSetupTelemetry,
public readonly telemetry: IAuthMetadataSource,
private readonly _log: AuthMetadataLogger,
) {
this._scopes = scopes;
Expand All @@ -880,8 +883,8 @@ class AuthMetadata implements IAuthMetadata {
return this._scopes;
}

update(response: CommonResponse): boolean {
const scopesChallenge = this._parseScopesFromResponse(response);
update(responseHeaders: Headers): boolean {
const scopesChallenge = this._parseScopesFromResponse(responseHeaders);
if (!scopesMatch(scopesChallenge, this._scopes)) {
this._log(LogLevel.Info, `Scopes changed from ${JSON.stringify(this._scopes)} to ${JSON.stringify(scopesChallenge)}, updating`);
this._scopes = scopesChallenge;
Expand All @@ -890,12 +893,11 @@ class AuthMetadata implements IAuthMetadata {
return false;
}

private _parseScopesFromResponse(response: CommonResponse): string[] | undefined {
if (!response.headers.has('WWW-Authenticate')) {
private _parseScopesFromResponse(responseHeaders: Headers): string[] | undefined {
const authHeader = responseHeaders.get('WWW-Authenticate');
if (!authHeader) {
return undefined;
}

const authHeader = response.headers.get('WWW-Authenticate')!;
const challenges = parseWWWAuthenticateHeader(authHeader);
for (const challenge of challenges) {
if (challenge.scheme === 'Bearer' && challenge.params['scope']) {
Expand All @@ -914,8 +916,8 @@ class AuthMetadata implements IAuthMetadata {
* Options for creating AuthMetadata.
*/
export interface ICreateAuthMetadataOptions {
/** Headers to include when fetching metadata from the same origin as the MCP server */
launchHeaders: Iterable<readonly [string, string]>;
/** Headers to include when fetching metadata from the same origin as the resource server */
sameOriginHeaders?: Record<string, string>;
/** Fetch function to use for HTTP requests */
fetch: (url: string, init: MinimalRequestInit) => Promise<CommonResponse>;
/** Logger function for diagnostic output */
Expand All @@ -931,36 +933,33 @@ export interface ICreateAuthMetadataOptions {
* 3. Fetches authorization server metadata
* 4. Falls back to default metadata if discovery fails
*
* @param mcpUrl The MCP server URL
* @param originalResponse The original HTTP response that triggered auth (typically 401/403)
* @param resourceUrl The resource server URL
* @param wwwAuthenticateValue The value of the WWW-Authenticate header from the original HTTP response
* @param options Configuration options including headers, fetch function, and logger
* @returns A new AuthMetadata instance
*/
export async function createAuthMetadata(
mcpUrl: string,
originalResponse: CommonResponse,
resourceUrl: string,
initialResponseHeaders: Headers,
options: ICreateAuthMetadataOptions
): Promise<AuthMetadata> {
const { launchHeaders, fetch, log } = options;
const { sameOriginHeaders, fetch, log } = options;

// Track discovery sources for telemetry
let resourceMetadataSource = McpAuthResourceMetadataSource.None;
let serverMetadataSource: McpAuthServerMetadataSource | undefined;
let resourceMetadataSource = IAuthResourceMetadataSource.None;
let serverMetadataSource: IAuthServerMetadataSource | undefined;

// Parse the WWW-Authenticate header for resource_metadata and scope challenges
const { resourceMetadataChallenge, scopesChallenge: scopesChallengeFromHeader } = parseWWWAuthenticateHeaderForChallenges(originalResponse, log);
const { resourceMetadataChallenge, scopesChallenge: scopesChallengeFromHeader } = parseWWWAuthenticateHeaderForChallenges(initialResponseHeaders.get('WWW-Authenticate') ?? undefined, log);

// Fetch the resource metadata either from the challenge URL or from well-known URIs
let serverMetadataUrl: string | undefined;
let resource: IAuthorizationProtectedResourceMetadata | undefined;
let scopesChallenge = scopesChallengeFromHeader;

try {
const { metadata, discoveryUrl, errors } = await fetchResourceMetadata(mcpUrl, resourceMetadataChallenge, {
sameOriginHeaders: {
...Object.fromEntries(launchHeaders),
'MCP-Protocol-Version': MCP.LATEST_PROTOCOL_VERSION
},
const { metadata, discoveryUrl, errors } = await fetchResourceMetadata(resourceUrl, resourceMetadataChallenge, {
sameOriginHeaders,
fetch: (url, init) => fetch(url, init as MinimalRequestInit)
});
for (const err of errors) {
Expand All @@ -969,7 +968,7 @@ export async function createAuthMetadata(
log(LogLevel.Info, `Discovered resource metadata at ${discoveryUrl}`);

// Determine if resource metadata came from header or well-known
resourceMetadataSource = resourceMetadataChallenge ? McpAuthResourceMetadataSource.Header : McpAuthResourceMetadataSource.WellKnown;
resourceMetadataSource = resourceMetadataChallenge ? IAuthResourceMetadataSource.Header : IAuthResourceMetadataSource.WellKnown;

// TODO:@TylerLeonhardt support multiple authorization servers
// Consider using one that has an auth provider first, over the dynamic flow
Expand All @@ -978,26 +977,25 @@ export async function createAuthMetadata(
log(LogLevel.Warning, `No authorization_servers found in resource metadata ${discoveryUrl} - Is this resource metadata configured correctly?`);
} else {
log(LogLevel.Info, `Using auth server metadata url: ${serverMetadataUrl}`);
serverMetadataSource = McpAuthServerMetadataSource.ResourceMetadata;
serverMetadataSource = IAuthServerMetadataSource.ResourceMetadata;
}
scopesChallenge ??= metadata.scopes_supported;
resource = metadata;
} catch (e) {
log(LogLevel.Warning, `Could not fetch resource metadata: ${String(e)}`);
}

const baseUrl = new URL(originalResponse.url).origin;
const baseUrl = new URL(resourceUrl).origin;

// If we are not given a resource_metadata, see if the well-known server metadata is available
// on the base url.
let additionalHeaders: Record<string, string> = {};
if (!serverMetadataUrl) {
serverMetadataUrl = baseUrl;
// Maintain the launch headers when talking to the MCP origin.
additionalHeaders = {
...Object.fromEntries(launchHeaders),
'MCP-Protocol-Version': MCP.LATEST_PROTOCOL_VERSION
};
// Maintain the same origin headers when talking to the resource origin.
if (sameOriginHeaders) {
additionalHeaders = sameOriginHeaders;
}
}

try {
Expand All @@ -1013,7 +1011,7 @@ export async function createAuthMetadata(

// If serverMetadataSource is not yet defined, it means we fell back to baseUrl
// and successfully fetched from well-known
serverMetadataSource ??= McpAuthServerMetadataSource.WellKnown;
serverMetadataSource ??= IAuthServerMetadataSource.WellKnown;

return new AuthMetadata(
URI.parse(serverMetadataUrl),
Expand All @@ -1035,7 +1033,7 @@ export async function createAuthMetadata(
defaultMetadata,
resource,
scopesChallenge,
{ resourceMetadataSource, serverMetadataSource: McpAuthServerMetadataSource.Default },
{ resourceMetadataSource, serverMetadataSource: IAuthServerMetadataSource.Default },
log
);
}
Expand All @@ -1044,32 +1042,32 @@ export async function createAuthMetadata(
* Parses the WWW-Authenticate header for resource_metadata and scope challenges.
*/
function parseWWWAuthenticateHeaderForChallenges(
response: CommonResponse,
wwwAuthenticateValue: string | undefined,
log: AuthMetadataLogger
): { resourceMetadataChallenge: string | undefined; scopesChallenge: string[] | undefined } {
): { resourceMetadataChallenge?: string; scopesChallenge?: string[] } {
if (!wwwAuthenticateValue) {
return {};
}
let resourceMetadataChallenge: string | undefined;
let scopesChallenge: string[] | undefined;

if (response.headers.has('WWW-Authenticate')) {
const authHeader = response.headers.get('WWW-Authenticate')!;
const challenges = parseWWWAuthenticateHeader(authHeader);
for (const challenge of challenges) {
if (challenge.scheme === 'Bearer') {
if (!resourceMetadataChallenge && challenge.params['resource_metadata']) {
resourceMetadataChallenge = challenge.params['resource_metadata'];
log(LogLevel.Debug, `Found resource_metadata challenge in WWW-Authenticate header: ${resourceMetadataChallenge}`);
}
if (!scopesChallenge && challenge.params['scope']) {
const scopes = challenge.params['scope'].split(AUTH_SCOPE_SEPARATOR).filter(s => s.trim().length);
if (scopes.length) {
log(LogLevel.Debug, `Found scope challenge in WWW-Authenticate header: ${challenge.params['scope']}`);
scopesChallenge = scopes;
}
}
if (resourceMetadataChallenge && scopesChallenge) {
break;
const challenges = parseWWWAuthenticateHeader(wwwAuthenticateValue);
for (const challenge of challenges) {
if (challenge.scheme === 'Bearer') {
if (!resourceMetadataChallenge && challenge.params['resource_metadata']) {
resourceMetadataChallenge = challenge.params['resource_metadata'];
log(LogLevel.Debug, `Found resource_metadata challenge in WWW-Authenticate header: ${resourceMetadataChallenge}`);
}
if (!scopesChallenge && challenge.params['scope']) {
const scopes = challenge.params['scope'].split(AUTH_SCOPE_SEPARATOR).filter(s => s.trim().length);
if (scopes.length) {
log(LogLevel.Debug, `Found scope challenge in WWW-Authenticate header: ${challenge.params['scope']}`);
scopesChallenge = scopes;
}
}
if (resourceMetadataChallenge && scopesChallenge) {
break;
}
}
}
return { resourceMetadataChallenge, scopesChallenge };
Expand Down
Loading
Loading