Path: blob/main/src/vs/platform/mcp/common/mcpGalleryService.ts
3294 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 { CancellationToken } from '../../../base/common/cancellation.js';6import { MarkdownString } from '../../../base/common/htmlContent.js';7import { Disposable } from '../../../base/common/lifecycle.js';8import { Schemas } from '../../../base/common/network.js';9import { format2, uppercaseFirstLetter } from '../../../base/common/strings.js';10import { URI } from '../../../base/common/uri.js';11import { localize } from '../../../nls.js';12import { IFileService } from '../../files/common/files.js';13import { ILogService } from '../../log/common/log.js';14import { IProductService } from '../../product/common/productService.js';15import { asJson, asText, IRequestService } from '../../request/common/request.js';16import { IGalleryMcpServer, GalleryMcpServerStatus, IMcpGalleryService, IGalleryMcpServerConfiguration, IMcpServerPackage, IMcpServerRemote, IQueryOptions } from './mcpManagement.js';17import { IMcpGalleryManifestService, McpGalleryManifestStatus, getMcpGalleryManifestResourceUri, McpGalleryResourceType, IMcpGalleryManifest } from './mcpGalleryManifest.js';18import { IPageIterator, IPager, PageIteratorPager, singlePagePager } from '../../../base/common/paging.js';19import { CancellationError } from '../../../base/common/errors.js';20import { basename } from '../../../base/common/path.js';2122interface IRawGalleryServerListMetadata {23readonly count: number;24readonly total?: number;25readonly next_cursor?: string;26}2728interface IGitHubInfo {29readonly 'name': string;30readonly 'name_with_owner': string;31readonly 'is_in_organization'?: boolean;32readonly 'license'?: string;33readonly 'opengraph_image_url'?: string;34readonly 'owner_avatar_url'?: string;35readonly 'primary_language'?: string;36readonly 'primary_language_color'?: string;37readonly 'pushed_at'?: string;38readonly 'stargazer_count'?: number;39readonly 'topics'?: readonly string[];40readonly 'uses_custom_opengraph_image'?: boolean;41}4243interface IRawGalleryMcpServerMetaData {44readonly 'x-io.modelcontextprotocol.registry'?: {45readonly id: string;46readonly published_at: string;47readonly updated_at: string;48readonly is_latest: boolean;49readonly release_date?: string;50};51readonly 'x-publisher'?: Record<string, any>;52readonly 'x-github'?: IGitHubInfo;53readonly 'github'?: IGitHubInfo;54}5556function isIRawGalleryServersOldResult(obj: any): obj is IRawGalleryServersOldResult {57return obj && Array.isArray(obj.servers) && isIRawGalleryOldMcpServer(obj.servers[0]);58}5960function isIRawGalleryOldMcpServer(obj: any): obj is IRawGalleryOldMcpServer {61return obj && obj.server !== undefined;62}6364interface IRawGalleryServersResult {65readonly metadata?: IRawGalleryServerListMetadata;66readonly servers: readonly IRawGalleryMcpServer[];67}6869interface IRawGalleryServersOldResult {70readonly metadata?: IRawGalleryServerListMetadata;71readonly servers: readonly IRawGalleryOldMcpServer[];72}7374interface IRawGalleryOldMcpServer extends IRawGalleryMcpServerMetaData {75readonly server: IRawGalleryMcpServerDetail;76}7778interface IRawGalleryMcpServer extends IRawGalleryMcpServerDetail {79readonly _meta?: IRawGalleryMcpServerMetaData;80}8182interface IRawGalleryMcpServerPackage extends IMcpServerPackage {83readonly registry_name: string;84readonly name: string;85}8687interface IRawGalleryMcpServerDetail {88readonly id: string;89readonly name: string;90readonly description: string;91readonly version_detail: {92readonly version: string;93readonly release_date: string;94readonly is_latest: boolean;95};96readonly status?: GalleryMcpServerStatus;97readonly repository?: {98readonly url: string;99readonly source: string;100readonly id: string;101readonly readme?: string;102};103readonly created_at: string;104readonly updated_at: string;105readonly packages?: readonly IRawGalleryMcpServerPackage[];106readonly remotes?: readonly IMcpServerRemote[];107}108109interface IVSCodeGalleryMcpServerDetail {110readonly name: string;111readonly displayName: string;112readonly description: string;113readonly repository?: {114readonly url: string;115readonly source: string;116};117readonly codicon?: string;118readonly iconUrl?: string;119readonly iconUrlDark?: string;120readonly iconUrlLight?: string;121readonly readmeUrl: string;122readonly publisher?: {123readonly displayName: string;124readonly url: string;125readonly is_verified: boolean;126};127readonly manifest: {128readonly packages?: readonly IRawGalleryMcpServerPackage[];129readonly remotes?: readonly IMcpServerRemote[];130};131}132133const DefaultPageSize = 50;134135interface IQueryState {136readonly searchText?: string;137readonly cursor?: string;138readonly pageSize: number;139}140141const DefaultQueryState: IQueryState = {142pageSize: DefaultPageSize,143};144145class Query {146147constructor(private state = DefaultQueryState) { }148149get pageSize(): number { return this.state.pageSize; }150get searchText(): string | undefined { return this.state.searchText; }151152153withPage(cursor: string, pageSize: number = this.pageSize): Query {154return new Query({ ...this.state, pageSize, cursor });155}156157withSearchText(searchText: string): Query {158return new Query({ ...this.state, searchText });159}160}161162export class McpGalleryService extends Disposable implements IMcpGalleryService {163164_serviceBrand: undefined;165166constructor(167@IRequestService private readonly requestService: IRequestService,168@IFileService private readonly fileService: IFileService,169@IProductService private readonly productService: IProductService,170@ILogService private readonly logService: ILogService,171@IMcpGalleryManifestService private readonly mcpGalleryManifestService: IMcpGalleryManifestService,172) {173super();174}175176isEnabled(): boolean {177return this.mcpGalleryManifestService.mcpGalleryManifestStatus === McpGalleryManifestStatus.Available;178}179180async query(options?: IQueryOptions, token: CancellationToken = CancellationToken.None): Promise<IPager<IGalleryMcpServer>> {181const mcpGalleryManifest = await this.mcpGalleryManifestService.getMcpGalleryManifest();182if (!mcpGalleryManifest) {183return singlePagePager([]);184}185186const query = new Query();187const { servers, metadata } = await this.queryGalleryMcpServers(query, mcpGalleryManifest, token);188const total = metadata?.total ?? metadata?.count ?? servers.length;189190const getNextPage = async (cursor: string | undefined, ct: CancellationToken): Promise<IPageIterator<IGalleryMcpServer>> => {191if (ct.isCancellationRequested) {192throw new CancellationError();193}194const { servers, metadata } = cursor ? await this.queryGalleryMcpServers(query.withPage(cursor), mcpGalleryManifest, token) : { servers: [], metadata: undefined };195return {196elements: servers,197total,198hasNextPage: !!cursor,199getNextPage: (token) => getNextPage(metadata?.next_cursor, token)200};201};202203return new PageIteratorPager({204elements: servers,205total,206hasNextPage: !!metadata?.next_cursor,207getNextPage: (token) => getNextPage(metadata?.next_cursor, token),208209});210}211212async getMcpServersFromGallery(urls: string[]): Promise<IGalleryMcpServer[]> {213const mcpGalleryManifest = await this.mcpGalleryManifestService.getMcpGalleryManifest();214if (!mcpGalleryManifest) {215return [];216}217218const mcpServers: IGalleryMcpServer[] = [];219await Promise.allSettled(urls.map(async url => {220const mcpServerUrl = this.getServerUrl(basename(url), mcpGalleryManifest);221if (mcpServerUrl !== url) {222return;223}224const mcpServer = await this.getMcpServer(mcpServerUrl);225if (mcpServer) {226mcpServers.push(mcpServer);227}228}));229230return mcpServers;231}232233async getMcpServersFromVSCodeGallery(names: string[]): Promise<IGalleryMcpServer[]> {234const servers = await this.fetchMcpServersFromVSCodeGallery();235return servers.filter(item => names.includes(item.name));236}237238async getMcpServerConfiguration(gallery: IGalleryMcpServer, token: CancellationToken): Promise<IGalleryMcpServerConfiguration> {239if (gallery.configuration) {240return gallery.configuration;241}242243if (!gallery.url) {244throw new Error(`No manifest URL found for ${gallery.name}`);245}246247const context = await this.requestService.request({248type: 'GET',249url: gallery.url,250}, token);251252const result = await asJson<IRawGalleryMcpServer | IRawGalleryOldMcpServer>(context);253if (!result) {254throw new Error(`Failed to fetch configuration from ${gallery.url}`);255}256257const server = this.toIRawGalleryMcpServer(result);258const configuration = this.toGalleryMcpServerConfiguration(server.packages, server.remotes);259if (!configuration) {260throw new Error(`Failed to fetch configuration for ${gallery.url}`);261}262263return configuration;264}265266async getReadme(gallery: IGalleryMcpServer, token: CancellationToken): Promise<string> {267const readmeUrl = gallery.readmeUrl;268if (!readmeUrl) {269return Promise.resolve(localize('noReadme', 'No README available'));270}271272const uri = URI.parse(readmeUrl);273if (uri.scheme === Schemas.file) {274try {275const content = await this.fileService.readFile(uri);276return content.value.toString();277} catch (error) {278this.logService.error(`Failed to read file from ${uri}: ${error}`);279}280}281282if (uri.authority !== 'raw.githubusercontent.com') {283return new MarkdownString(localize('readme.viewInBrowser', "You can find information about this server [here]({0})", readmeUrl)).value;284}285286const context = await this.requestService.request({287type: 'GET',288url: readmeUrl,289}, token);290291const result = await asText(context);292if (!result) {293throw new Error(`Failed to fetch README from ${readmeUrl}`);294}295296return result;297}298299private toGalleryMcpServer(server: IRawGalleryMcpServer, serverUrl: string | undefined): IGalleryMcpServer {300const registryInfo = server._meta?.['x-io.modelcontextprotocol.registry'];301const githubInfo = server._meta?.['github'] ?? server._meta?.['x-github'];302303let publisher = '';304let displayName = '';305306if (githubInfo?.name) {307displayName = githubInfo.name.split('-').map(s => uppercaseFirstLetter(s)).join(' ');308publisher = githubInfo.name_with_owner.split('/')[0];309} else {310const nameParts = server.name.split('/');311if (nameParts.length > 0) {312const domainParts = nameParts[0].split('.');313if (domainParts.length > 0) {314publisher = domainParts[domainParts.length - 1]; // Always take the last part as owner315}316}317displayName = nameParts[nameParts.length - 1].split('-').map(s => uppercaseFirstLetter(s)).join(' ');318}319320const icon: { light: string; dark: string } | undefined = githubInfo?.owner_avatar_url ? {321light: githubInfo.owner_avatar_url,322dark: githubInfo.owner_avatar_url323} : undefined;324325return {326id: server.id,327name: server.name,328displayName,329url: serverUrl,330description: server.description,331status: server.status ?? GalleryMcpServerStatus.Active,332version: server.version_detail.version,333isLatest: server.version_detail.is_latest,334releaseDate: Date.parse(server.version_detail.release_date),335publishDate: registryInfo ? Date.parse(registryInfo.published_at) : undefined,336lastUpdated: registryInfo ? Date.parse(registryInfo.updated_at) : undefined,337repositoryUrl: server.repository?.url,338readme: server.repository?.readme,339icon,340publisher,341license: githubInfo?.license,342starsCount: githubInfo?.stargazer_count,343topics: githubInfo?.topics,344configuration: this.toGalleryMcpServerConfiguration(server.packages, server.remotes)345};346}347348private toGalleryMcpServerConfiguration(packages?: readonly IRawGalleryMcpServerPackage[], remotes?: readonly IMcpServerRemote[]): IGalleryMcpServerConfiguration | undefined {349if (!packages && !remotes) {350return undefined;351}352353return {354packages: packages?.map(p => ({355...p,356identifier: p.identifier ?? p.name,357registry_type: p.registry_type ?? p.registry_name358})),359remotes360};361}362363private async queryGalleryMcpServers(query: Query, mcpGalleryManifest: IMcpGalleryManifest, token: CancellationToken): Promise<{ servers: IGalleryMcpServer[]; metadata?: IRawGalleryServerListMetadata }> {364if (mcpGalleryManifest.url === this.productService.extensionsGallery?.mcpUrl) {365return {366servers: await this.fetchMcpServersFromVSCodeGallery()367};368}369const { servers, metadata } = await this.queryRawGalleryMcpServers(query, mcpGalleryManifest, token);370return {371servers: servers.map(item => this.toGalleryMcpServer(item, this.getServerUrl(item.id, mcpGalleryManifest))),372metadata373};374}375376private async queryRawGalleryMcpServers(query: Query, mcpGalleryManifest: IMcpGalleryManifest, token: CancellationToken): Promise<IRawGalleryServersResult> {377const mcpGalleryUrl = this.getMcpGalleryUrl(mcpGalleryManifest);378if (!mcpGalleryUrl) {379return { servers: [] };380}381382const uri = URI.parse(mcpGalleryUrl);383if (uri.scheme === Schemas.file) {384try {385const content = await this.fileService.readFile(uri);386const data = content.value.toString();387return JSON.parse(data);388} catch (error) {389this.logService.error(`Failed to read file from ${uri}: ${error}`);390}391}392393const url = `${mcpGalleryUrl}?limit=${query.pageSize}`;394395const context = await this.requestService.request({396type: 'GET',397url,398}, token);399400const result = await asJson<IRawGalleryServersResult | IRawGalleryServersOldResult>(context);401402if (!result) {403return { servers: [] };404}405406if (isIRawGalleryServersOldResult(result)) {407return {408servers: result.servers.map<IRawGalleryMcpServer>(server => this.toIRawGalleryMcpServer(server)),409metadata: result.metadata410};411}412413return result;414}415416async getMcpServer(mcpServerUrl: string): Promise<IGalleryMcpServer | undefined> {417const context = await this.requestService.request({418type: 'GET',419url: mcpServerUrl,420}, CancellationToken.None);421422if (context.res.statusCode && context.res.statusCode >= 400 && context.res.statusCode < 500) {423return undefined;424}425426const server = await asJson<IRawGalleryMcpServer | IRawGalleryOldMcpServer>(context);427if (!server) {428return undefined;429}430431return this.toGalleryMcpServer(this.toIRawGalleryMcpServer(server), mcpServerUrl);432}433434private toIRawGalleryMcpServer(from: IRawGalleryOldMcpServer | IRawGalleryMcpServer): IRawGalleryMcpServer {435if (isIRawGalleryOldMcpServer(from)) {436return {437...from.server,438_meta: {439'x-io.modelcontextprotocol.registry': from['x-io.modelcontextprotocol.registry'],440'github': from['x-github'],441'x-publisher': from['x-publisher']442}443};444}445return from;446}447448private async fetchMcpServersFromVSCodeGallery(): Promise<IGalleryMcpServer[]> {449const mcpGalleryUrl = this.productService.extensionsGallery?.mcpUrl;450if (!mcpGalleryUrl) {451return [];452}453454const context = await this.requestService.request({455type: 'GET',456url: mcpGalleryUrl,457}, CancellationToken.None);458459const result = await asJson<{ servers: IVSCodeGalleryMcpServerDetail[] }>(context);460if (!result) {461return [];462}463464return result.servers.map<IGalleryMcpServer>(item => {465return {466id: item.name,467name: item.name,468displayName: item.displayName,469description: item.description,470version: '0.0.1',471isLatest: true,472status: GalleryMcpServerStatus.Active,473repositoryUrl: item.repository?.url,474codicon: item.codicon,475publisher: '',476publisherDisplayName: item.publisher?.displayName,477publisherDomain: item.publisher ? {478link: item.publisher.url,479verified: item.publisher.is_verified,480} : undefined,481readmeUrl: item.readmeUrl,482configuration: this.toGalleryMcpServerConfiguration(item.manifest.packages, item.manifest.remotes)483};484});485}486487private getServerUrl(id: string, mcpGalleryManifest: IMcpGalleryManifest): string | undefined {488const resourceUriTemplate = getMcpGalleryManifestResourceUri(mcpGalleryManifest, McpGalleryResourceType.McpServerManifestUri);489if (!resourceUriTemplate) {490return undefined;491}492return format2(resourceUriTemplate, { id });493}494495private getMcpGalleryUrl(mcpGalleryManifest: IMcpGalleryManifest): string | undefined {496return getMcpGalleryManifestResourceUri(mcpGalleryManifest, McpGalleryResourceType.McpQueryService);497}498499}500501502