Path: blob/main/src/vs/workbench/contrib/mcp/common/mcpResourceFilesystem.ts
5251 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 { sumBy } from '../../../../base/common/arrays.js';6import { disposableTimeout } from '../../../../base/common/async.js';7import { decodeBase64, VSBuffer } from '../../../../base/common/buffer.js';8import { CancellationToken, CancellationTokenPool, CancellationTokenSource } from '../../../../base/common/cancellation.js';9import { Emitter, Event } from '../../../../base/common/event.js';10import { Lazy } from '../../../../base/common/lazy.js';11import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';12import { ResourceMap } from '../../../../base/common/map.js';13import { autorun } from '../../../../base/common/observable.js';14import { newWriteableStream, ReadableStreamEvents } from '../../../../base/common/stream.js';15import { equalsIgnoreCase } from '../../../../base/common/strings.js';16import { URI } from '../../../../base/common/uri.js';17import { createFileSystemProviderError, FileChangeType, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileChange, IFileDeleteOptions, IFileOverwriteOptions, IFileReadStreamOptions, IFileService, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileWriteOptions, IStat, IWatchOptions } from '../../../../platform/files/common/files.js';18import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';19import { IWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js';20import { IWorkbenchContribution } from '../../../common/contributions.js';21import { McpServer } from './mcpServer.js';22import { McpServerRequestHandler } from './mcpServerRequestHandler.js';23import { IMcpService, McpCapability, McpResourceURI } from './mcpTypes.js';24import { canLoadMcpNetworkResourceDirectly } from './mcpTypesUtils.js';25import { MCP } from './modelContextProtocol.js';2627const MOMENTARY_CACHE_DURATION = 3000;2829interface IReadData {30contents: (MCP.TextResourceContents | MCP.BlobResourceContents)[];31resourceURI: URL;32forSameURI: (MCP.TextResourceContents | MCP.BlobResourceContents)[];33}3435export class McpResourceFilesystem extends Disposable implements IWorkbenchContribution,36IFileSystemProviderWithFileReadWriteCapability,37IFileSystemProviderWithFileAtomicReadCapability,38IFileSystemProviderWithFileReadStreamCapability {39/** Defer getting the MCP service since this is a BlockRestore and no need to make it unnecessarily. */40private readonly _mcpServiceLazy = new Lazy(() => this._instantiationService.invokeFunction(a => a.get(IMcpService)));4142/**43* For many file operations we re-read the resources quickly (e.g. stat44* before reading the file) and would prefer to avoid spamming the MCP45* with multiple reads. This is a very short-duration cache46* to solve that.47*/48private readonly _momentaryCache = new ResourceMap<{ pool: CancellationTokenPool; promise: Promise<IReadData> }>();4950private get _mcpService() {51return this._mcpServiceLazy.value;52}5354public readonly onDidChangeCapabilities = Event.None;5556private readonly _onDidChangeFile = this._register(new Emitter<readonly IFileChange[]>());57public readonly onDidChangeFile = this._onDidChangeFile.event;5859public readonly capabilities: FileSystemProviderCapabilities = FileSystemProviderCapabilities.None60| FileSystemProviderCapabilities.Readonly61| FileSystemProviderCapabilities.PathCaseSensitive62| FileSystemProviderCapabilities.FileReadStream63| FileSystemProviderCapabilities.FileAtomicRead64| FileSystemProviderCapabilities.FileReadWrite;6566constructor(67@IInstantiationService private readonly _instantiationService: IInstantiationService,68@IFileService private readonly _fileService: IFileService,69@IWebContentExtractorService private readonly _webContentExtractorService: IWebContentExtractorService,70) {71super();72this._register(this._fileService.registerProvider(McpResourceURI.scheme, this));73}7475//#region Filesystem API7677public async readFile(resource: URI): Promise<Uint8Array> {78return this._readFile(resource);79}8081public readFileStream(resource: URI, opts: IFileReadStreamOptions, token: CancellationToken): ReadableStreamEvents<Uint8Array> {82const stream = newWriteableStream<Uint8Array>(data => VSBuffer.concat(data.map(data => VSBuffer.wrap(data))).buffer);8384this._readFile(resource, token).then(85data => {86if (opts.position) {87data = data.slice(opts.position);88}8990if (opts.length) {91data = data.slice(0, opts.length);92}9394stream.end(data);95},96err => stream.error(err),97);9899return stream;100}101102public watch(uri: URI, _opts: IWatchOptions): IDisposable {103const { resourceURI, server } = this._decodeURI(uri);104const cap = server.capabilities.get();105if (cap !== undefined && !(cap & McpCapability.ResourcesSubscribe)) {106return Disposable.None;107}108109server.start();110111const store = new DisposableStore();112let watchedOnHandler: McpServerRequestHandler | undefined;113const watchListener = store.add(new MutableDisposable());114const callCts = store.add(new MutableDisposable<CancellationTokenSource>());115store.add(autorun(reader => {116const connection = server.connection.read(reader);117if (!connection) {118return;119}120121const handler = connection.handler.read(reader);122if (!handler || watchedOnHandler === handler) {123return;124}125126callCts.value?.dispose(true);127callCts.value = new CancellationTokenSource();128watchedOnHandler = handler;129130const token = callCts.value.token;131handler.subscribe({ uri: resourceURI.toString() }, token).then(132() => {133if (!token.isCancellationRequested) {134watchListener.value = handler.onDidUpdateResource(e => {135if (equalsUrlPath(e.params.uri, resourceURI)) {136this._onDidChangeFile.fire([{ resource: uri, type: FileChangeType.UPDATED }]);137}138});139}140}, err => {141handler.logger.warn(`Failed to subscribe to resource changes for ${resourceURI}: ${err}`);142watchedOnHandler = undefined;143},144);145}));146147return store;148}149150public async stat(resource: URI): Promise<IStat> {151const { forSameURI, contents } = await this._readURI(resource);152if (!contents.length) {153throw createFileSystemProviderError(`File not found`, FileSystemProviderErrorCode.FileNotFound);154}155156return {157ctime: 0,158mtime: 0,159size: sumBy(contents, c => contentToBuffer(c).byteLength),160type: forSameURI.length ? FileType.File : FileType.Directory,161};162}163164public async readdir(resource: URI): Promise<[string, FileType][]> {165const { forSameURI, contents, resourceURI } = await this._readURI(resource);166if (forSameURI.length > 0) {167throw createFileSystemProviderError(`File is not a directory`, FileSystemProviderErrorCode.FileNotADirectory);168}169const resourcePathParts = resourceURI.pathname.split('/');170171const output = new Map<string, FileType>();172for (const content of contents) {173const contentURI = URI.parse(content.uri);174const contentPathParts = contentURI.path.split('/');175176// Skip contents that are not in the same directory177if (contentPathParts.length <= resourcePathParts.length || !resourcePathParts.every((part, index) => equalsIgnoreCase(part, contentPathParts[index]))) {178continue;179}180181// nested resource in a directory, just emit a directory to output182else if (contentPathParts.length > resourcePathParts.length + 1) {183output.set(contentPathParts[resourcePathParts.length], FileType.Directory);184}185186else {187// resource in the same directory, emit the file188const name = contentPathParts[contentPathParts.length - 1];189output.set(name, contentToBuffer(content).byteLength > 0 ? FileType.File : FileType.Directory);190}191}192193return [...output];194}195196public mkdir(resource: URI): Promise<void> {197throw createFileSystemProviderError('write is not supported', FileSystemProviderErrorCode.NoPermissions);198}199public writeFile(resource: URI, content: Uint8Array, opts: IFileWriteOptions): Promise<void> {200throw createFileSystemProviderError('write is not supported', FileSystemProviderErrorCode.NoPermissions);201}202public delete(resource: URI, opts: IFileDeleteOptions): Promise<void> {203throw createFileSystemProviderError('delete is not supported', FileSystemProviderErrorCode.NoPermissions);204}205public rename(from: URI, to: URI, opts: IFileOverwriteOptions): Promise<void> {206throw createFileSystemProviderError('rename is not supported', FileSystemProviderErrorCode.NoPermissions);207}208209//#endregion210211private async _readFile(resource: URI, token?: CancellationToken): Promise<Uint8Array> {212const { forSameURI, contents } = await this._readURI(resource);213214// MCP does not distinguish between files and directories, and says that215// servers should just return multiple when 'reading' a directory.216if (!forSameURI.length) {217if (!contents.length) {218throw createFileSystemProviderError(`File not found`, FileSystemProviderErrorCode.FileNotFound);219} else {220throw createFileSystemProviderError(`File is a directory`, FileSystemProviderErrorCode.FileIsADirectory);221}222}223224return contentToBuffer(forSameURI[0]);225}226227private _decodeURI(uri: URI) {228let definitionId: string;229let resourceURL: URL;230try {231({ definitionId, resourceURL } = McpResourceURI.toServer(uri));232} catch (e) {233throw createFileSystemProviderError(String(e), FileSystemProviderErrorCode.FileNotFound);234}235236if (resourceURL.pathname.endsWith('/')) {237resourceURL.pathname = resourceURL.pathname.slice(0, -1);238}239240const server = this._mcpService.servers.get().find(s => s.definition.id === definitionId);241if (!server) {242throw createFileSystemProviderError(`MCP server ${definitionId} not found`, FileSystemProviderErrorCode.FileNotFound);243}244245const cap = server.capabilities.get();246if (cap !== undefined && !(cap & McpCapability.Resources)) {247throw createFileSystemProviderError(`MCP server ${definitionId} does not support resources`, FileSystemProviderErrorCode.FileNotFound);248}249250return { definitionId, resourceURI: resourceURL, server };251}252253private async _readURI(uri: URI, token?: CancellationToken) {254const cached = this._momentaryCache.get(uri);255if (cached) {256cached.pool.add(token || CancellationToken.None);257return cached.promise;258}259260const pool = this._store.add(new CancellationTokenPool());261pool.add(token || CancellationToken.None);262263const promise = this._readURIInner(uri, pool.token);264this._momentaryCache.set(uri, { pool, promise });265266const disposable = this._store.add(disposableTimeout(() => {267this._momentaryCache.delete(uri);268this._store.delete(disposable);269this._store.delete(pool);270}, MOMENTARY_CACHE_DURATION));271272return promise;273}274275private async _readURIInner(uri: URI, token?: CancellationToken): Promise<IReadData> {276const { resourceURI, server } = this._decodeURI(uri);277const matchedServer = this._mcpService.servers.get().find(s => s.definition.id === server.definition.id);278279//check for http/https resources and use web content extractor service to fetch the contents.280if (canLoadMcpNetworkResourceDirectly(resourceURI, matchedServer)) {281const extractURI = URI.parse(resourceURI.toString());282const result = (await this._webContentExtractorService.extract([extractURI], { followRedirects: false })).at(0);283if (result?.status === 'ok') {284return {285contents: [{ uri: resourceURI.toString(), text: result.result }],286resourceURI,287forSameURI: [{ uri: resourceURI.toString(), text: result.result }]288};289}290}291292const res = await McpServer.callOn(server, r => r.readResource({ uri: resourceURI.toString() }, token), token);293return {294contents: res.contents,295resourceURI,296forSameURI: res.contents.filter(c => equalsUrlPath(c.uri, resourceURI))297};298}299}300301function equalsUrlPath(a: string, b: URL): boolean {302// MCP doesn't specify either way, but underlying systems may can be case-sensitive.303// It's better to treat case-sensitive paths as case-insensitive than vise-versa.304return equalsIgnoreCase(new URL(a).pathname, b.pathname);305}306307function contentToBuffer(content: MCP.TextResourceContents | MCP.BlobResourceContents): Uint8Array {308if ('text' in content) {309return VSBuffer.fromString(content.text).buffer;310} else if ('blob' in content) {311return decodeBase64(content.blob).buffer;312} else {313throw createFileSystemProviderError('Unknown content type', FileSystemProviderErrorCode.Unknown);314}315}316317318