Path: blob/main/src/vs/workbench/contrib/mcp/common/mcpResourceFilesystem.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 { sumBy } from '../../../../base/common/arrays.js';6import { decodeBase64, VSBuffer } from '../../../../base/common/buffer.js';7import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';8import { Emitter, Event } from '../../../../base/common/event.js';9import { Lazy } from '../../../../base/common/lazy.js';10import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';11import { autorun } from '../../../../base/common/observable.js';12import { newWriteableStream, ReadableStreamEvents } from '../../../../base/common/stream.js';13import { equalsIgnoreCase } from '../../../../base/common/strings.js';14import { URI } from '../../../../base/common/uri.js';15import { createFileSystemProviderError, FileChangeType, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileChange, IFileDeleteOptions, IFileOverwriteOptions, IFileReadStreamOptions, IFileService, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileWriteOptions, IStat, IWatchOptions } from '../../../../platform/files/common/files.js';16import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';17import { IWorkbenchContribution } from '../../../common/contributions.js';18import { McpServer } from './mcpServer.js';19import { McpServerRequestHandler } from './mcpServerRequestHandler.js';20import { IMcpService, McpCapability, McpResourceURI } from './mcpTypes.js';21import { MCP } from './modelContextProtocol.js';2223export class McpResourceFilesystem extends Disposable implements IWorkbenchContribution,24IFileSystemProviderWithFileReadWriteCapability,25IFileSystemProviderWithFileAtomicReadCapability,26IFileSystemProviderWithFileReadStreamCapability {27/** Defer getting the MCP service since this is a BlockRestore and no need to make it unnecessarily. */28private readonly _mcpServiceLazy = new Lazy(() => this._instantiationService.invokeFunction(a => a.get(IMcpService)));2930private get _mcpService() {31return this._mcpServiceLazy.value;32}3334public readonly onDidChangeCapabilities = Event.None;3536private readonly _onDidChangeFile = this._register(new Emitter<readonly IFileChange[]>());37public readonly onDidChangeFile = this._onDidChangeFile.event;3839public readonly capabilities: FileSystemProviderCapabilities = FileSystemProviderCapabilities.None40| FileSystemProviderCapabilities.Readonly41| FileSystemProviderCapabilities.PathCaseSensitive42| FileSystemProviderCapabilities.FileReadStream43| FileSystemProviderCapabilities.FileAtomicRead44| FileSystemProviderCapabilities.FileReadWrite;4546constructor(47@IInstantiationService private readonly _instantiationService: IInstantiationService,48@IFileService private readonly _fileService: IFileService,49) {50super();51this._register(this._fileService.registerProvider(McpResourceURI.scheme, this));52}5354//#region Filesystem API5556public async readFile(resource: URI): Promise<Uint8Array> {57return this._readFile(resource);58}5960public readFileStream(resource: URI, opts: IFileReadStreamOptions, token: CancellationToken): ReadableStreamEvents<Uint8Array> {61const stream = newWriteableStream<Uint8Array>(data => VSBuffer.concat(data.map(data => VSBuffer.wrap(data))).buffer);6263this._readFile(resource, token).then(64data => {65if (opts.position) {66data = data.slice(opts.position);67}6869if (opts.length) {70data = data.slice(0, opts.length);71}7273stream.end(data);74},75err => stream.error(err),76);7778return stream;79}8081public watch(uri: URI, _opts: IWatchOptions): IDisposable {82const { resourceURI, server } = this._decodeURI(uri);83const cap = server.capabilities.get();84if (cap !== undefined && !(cap & McpCapability.ResourcesSubscribe)) {85return Disposable.None;86}8788server.start();8990const store = new DisposableStore();91let watchedOnHandler: McpServerRequestHandler | undefined;92const watchListener = store.add(new MutableDisposable());93const callCts = store.add(new MutableDisposable<CancellationTokenSource>());94store.add(autorun(reader => {95const connection = server.connection.read(reader);96if (!connection) {97return;98}99100const handler = connection.handler.read(reader);101if (!handler || watchedOnHandler === handler) {102return;103}104105callCts.value?.dispose(true);106callCts.value = new CancellationTokenSource();107watchedOnHandler = handler;108109const token = callCts.value.token;110handler.subscribe({ uri: resourceURI.toString() }, token).then(111() => {112if (!token.isCancellationRequested) {113watchListener.value = handler.onDidUpdateResource(e => {114if (equalsUrlPath(e.params.uri, resourceURI)) {115this._onDidChangeFile.fire([{ resource: uri, type: FileChangeType.UPDATED }]);116}117});118}119}, err => {120handler.logger.warn(`Failed to subscribe to resource changes for ${resourceURI}: ${err}`);121watchedOnHandler = undefined;122},123);124}));125126return store;127}128129public async stat(resource: URI): Promise<IStat> {130const { forSameURI, contents } = await this._readURI(resource);131if (!contents.length) {132throw createFileSystemProviderError(`File not found`, FileSystemProviderErrorCode.FileNotFound);133}134135return {136ctime: 0,137mtime: 0,138size: sumBy(contents, c => contentToBuffer(c).byteLength),139type: forSameURI.length ? FileType.File : FileType.Directory,140};141}142143public async readdir(resource: URI): Promise<[string, FileType][]> {144const { forSameURI, contents, resourceURI } = await this._readURI(resource);145if (forSameURI.length > 0) {146throw createFileSystemProviderError(`File is not a directory`, FileSystemProviderErrorCode.FileNotADirectory);147}148149const resourcePathParts = resourceURI.pathname.split('/');150151const output = new Map<string, FileType>();152for (const content of contents) {153const contentURI = URI.parse(content.uri);154const contentPathParts = contentURI.path.split('/');155156// Skip contents that are not in the same directory157if (contentPathParts.length <= resourcePathParts.length || !resourcePathParts.every((part, index) => equalsIgnoreCase(part, contentPathParts[index]))) {158continue;159}160161// nested resource in a directory, just emit a directory to output162else if (contentPathParts.length > resourcePathParts.length + 1) {163output.set(contentPathParts[resourcePathParts.length], FileType.Directory);164}165166else {167// resource in the same directory, emit the file168const name = contentPathParts[contentPathParts.length - 1];169output.set(name, contentToBuffer(content).byteLength > 0 ? FileType.File : FileType.Directory);170}171}172173return [...output];174}175176public mkdir(resource: URI): Promise<void> {177throw createFileSystemProviderError('write is not supported', FileSystemProviderErrorCode.NoPermissions);178}179public writeFile(resource: URI, content: Uint8Array, opts: IFileWriteOptions): Promise<void> {180throw createFileSystemProviderError('write is not supported', FileSystemProviderErrorCode.NoPermissions);181}182public delete(resource: URI, opts: IFileDeleteOptions): Promise<void> {183throw createFileSystemProviderError('delete is not supported', FileSystemProviderErrorCode.NoPermissions);184}185public rename(from: URI, to: URI, opts: IFileOverwriteOptions): Promise<void> {186throw createFileSystemProviderError('rename is not supported', FileSystemProviderErrorCode.NoPermissions);187}188189//#endregion190191private async _readFile(resource: URI, token?: CancellationToken): Promise<Uint8Array> {192const { forSameURI, contents } = await this._readURI(resource);193194// MCP does not distinguish between files and directories, and says that195// servers should just return multiple when 'reading' a directory.196if (!forSameURI.length) {197if (!contents.length) {198throw createFileSystemProviderError(`File not found`, FileSystemProviderErrorCode.FileNotFound);199} else {200throw createFileSystemProviderError(`File is a directory`, FileSystemProviderErrorCode.FileIsADirectory);201}202}203204return contentToBuffer(forSameURI[0]);205}206207private _decodeURI(uri: URI) {208let definitionId: string;209let resourceURL: URL;210try {211({ definitionId, resourceURL } = McpResourceURI.toServer(uri));212} catch (e) {213throw createFileSystemProviderError(String(e), FileSystemProviderErrorCode.FileNotFound);214}215216if (resourceURL.pathname.endsWith('/')) {217resourceURL.pathname = resourceURL.pathname.slice(0, -1);218}219220const server = this._mcpService.servers.get().find(s => s.definition.id === definitionId);221if (!server) {222throw createFileSystemProviderError(`MCP server ${definitionId} not found`, FileSystemProviderErrorCode.FileNotFound);223}224225const cap = server.capabilities.get();226if (cap !== undefined && !(cap & McpCapability.Resources)) {227throw createFileSystemProviderError(`MCP server ${definitionId} does not support resources`, FileSystemProviderErrorCode.FileNotFound);228}229230return { definitionId, resourceURI: resourceURL, server };231}232233private async _readURI(uri: URI, token?: CancellationToken) {234const { resourceURI, server } = this._decodeURI(uri);235const res = await McpServer.callOn(server, r => r.readResource({ uri: resourceURI.toString() }, token), token);236237return {238contents: res.contents,239resourceURI,240forSameURI: res.contents.filter(c => equalsUrlPath(c.uri, resourceURI)),241};242}243}244245function equalsUrlPath(a: string, b: URL): boolean {246// MCP doesn't specify either way, but underlying systems may can be case-sensitive.247// It's better to treat case-sensitive paths as case-insensitive than vise-versa.248return equalsIgnoreCase(new URL(a).pathname, b.pathname);249}250251function contentToBuffer(content: MCP.TextResourceContents | MCP.BlobResourceContents): Uint8Array {252if ('text' in content) {253return VSBuffer.fromString(content.text).buffer;254} else if ('blob' in content) {255return decodeBase64(content.blob).buffer;256} else {257throw createFileSystemProviderError('Unknown content type', FileSystemProviderErrorCode.Unknown);258}259}260261262