Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/mcp/common/mcpResourceFilesystem.ts
5251 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { sumBy } from '../../../../base/common/arrays.js';
7
import { disposableTimeout } from '../../../../base/common/async.js';
8
import { decodeBase64, VSBuffer } from '../../../../base/common/buffer.js';
9
import { CancellationToken, CancellationTokenPool, CancellationTokenSource } from '../../../../base/common/cancellation.js';
10
import { Emitter, Event } from '../../../../base/common/event.js';
11
import { Lazy } from '../../../../base/common/lazy.js';
12
import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
13
import { ResourceMap } from '../../../../base/common/map.js';
14
import { autorun } from '../../../../base/common/observable.js';
15
import { newWriteableStream, ReadableStreamEvents } from '../../../../base/common/stream.js';
16
import { equalsIgnoreCase } from '../../../../base/common/strings.js';
17
import { URI } from '../../../../base/common/uri.js';
18
import { createFileSystemProviderError, FileChangeType, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileChange, IFileDeleteOptions, IFileOverwriteOptions, IFileReadStreamOptions, IFileService, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileWriteOptions, IStat, IWatchOptions } from '../../../../platform/files/common/files.js';
19
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
20
import { IWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js';
21
import { IWorkbenchContribution } from '../../../common/contributions.js';
22
import { McpServer } from './mcpServer.js';
23
import { McpServerRequestHandler } from './mcpServerRequestHandler.js';
24
import { IMcpService, McpCapability, McpResourceURI } from './mcpTypes.js';
25
import { canLoadMcpNetworkResourceDirectly } from './mcpTypesUtils.js';
26
import { MCP } from './modelContextProtocol.js';
27
28
const MOMENTARY_CACHE_DURATION = 3000;
29
30
interface IReadData {
31
contents: (MCP.TextResourceContents | MCP.BlobResourceContents)[];
32
resourceURI: URL;
33
forSameURI: (MCP.TextResourceContents | MCP.BlobResourceContents)[];
34
}
35
36
export class McpResourceFilesystem extends Disposable implements IWorkbenchContribution,
37
IFileSystemProviderWithFileReadWriteCapability,
38
IFileSystemProviderWithFileAtomicReadCapability,
39
IFileSystemProviderWithFileReadStreamCapability {
40
/** Defer getting the MCP service since this is a BlockRestore and no need to make it unnecessarily. */
41
private readonly _mcpServiceLazy = new Lazy(() => this._instantiationService.invokeFunction(a => a.get(IMcpService)));
42
43
/**
44
* For many file operations we re-read the resources quickly (e.g. stat
45
* before reading the file) and would prefer to avoid spamming the MCP
46
* with multiple reads. This is a very short-duration cache
47
* to solve that.
48
*/
49
private readonly _momentaryCache = new ResourceMap<{ pool: CancellationTokenPool; promise: Promise<IReadData> }>();
50
51
private get _mcpService() {
52
return this._mcpServiceLazy.value;
53
}
54
55
public readonly onDidChangeCapabilities = Event.None;
56
57
private readonly _onDidChangeFile = this._register(new Emitter<readonly IFileChange[]>());
58
public readonly onDidChangeFile = this._onDidChangeFile.event;
59
60
public readonly capabilities: FileSystemProviderCapabilities = FileSystemProviderCapabilities.None
61
| FileSystemProviderCapabilities.Readonly
62
| FileSystemProviderCapabilities.PathCaseSensitive
63
| FileSystemProviderCapabilities.FileReadStream
64
| FileSystemProviderCapabilities.FileAtomicRead
65
| FileSystemProviderCapabilities.FileReadWrite;
66
67
constructor(
68
@IInstantiationService private readonly _instantiationService: IInstantiationService,
69
@IFileService private readonly _fileService: IFileService,
70
@IWebContentExtractorService private readonly _webContentExtractorService: IWebContentExtractorService,
71
) {
72
super();
73
this._register(this._fileService.registerProvider(McpResourceURI.scheme, this));
74
}
75
76
//#region Filesystem API
77
78
public async readFile(resource: URI): Promise<Uint8Array> {
79
return this._readFile(resource);
80
}
81
82
public readFileStream(resource: URI, opts: IFileReadStreamOptions, token: CancellationToken): ReadableStreamEvents<Uint8Array> {
83
const stream = newWriteableStream<Uint8Array>(data => VSBuffer.concat(data.map(data => VSBuffer.wrap(data))).buffer);
84
85
this._readFile(resource, token).then(
86
data => {
87
if (opts.position) {
88
data = data.slice(opts.position);
89
}
90
91
if (opts.length) {
92
data = data.slice(0, opts.length);
93
}
94
95
stream.end(data);
96
},
97
err => stream.error(err),
98
);
99
100
return stream;
101
}
102
103
public watch(uri: URI, _opts: IWatchOptions): IDisposable {
104
const { resourceURI, server } = this._decodeURI(uri);
105
const cap = server.capabilities.get();
106
if (cap !== undefined && !(cap & McpCapability.ResourcesSubscribe)) {
107
return Disposable.None;
108
}
109
110
server.start();
111
112
const store = new DisposableStore();
113
let watchedOnHandler: McpServerRequestHandler | undefined;
114
const watchListener = store.add(new MutableDisposable());
115
const callCts = store.add(new MutableDisposable<CancellationTokenSource>());
116
store.add(autorun(reader => {
117
const connection = server.connection.read(reader);
118
if (!connection) {
119
return;
120
}
121
122
const handler = connection.handler.read(reader);
123
if (!handler || watchedOnHandler === handler) {
124
return;
125
}
126
127
callCts.value?.dispose(true);
128
callCts.value = new CancellationTokenSource();
129
watchedOnHandler = handler;
130
131
const token = callCts.value.token;
132
handler.subscribe({ uri: resourceURI.toString() }, token).then(
133
() => {
134
if (!token.isCancellationRequested) {
135
watchListener.value = handler.onDidUpdateResource(e => {
136
if (equalsUrlPath(e.params.uri, resourceURI)) {
137
this._onDidChangeFile.fire([{ resource: uri, type: FileChangeType.UPDATED }]);
138
}
139
});
140
}
141
}, err => {
142
handler.logger.warn(`Failed to subscribe to resource changes for ${resourceURI}: ${err}`);
143
watchedOnHandler = undefined;
144
},
145
);
146
}));
147
148
return store;
149
}
150
151
public async stat(resource: URI): Promise<IStat> {
152
const { forSameURI, contents } = await this._readURI(resource);
153
if (!contents.length) {
154
throw createFileSystemProviderError(`File not found`, FileSystemProviderErrorCode.FileNotFound);
155
}
156
157
return {
158
ctime: 0,
159
mtime: 0,
160
size: sumBy(contents, c => contentToBuffer(c).byteLength),
161
type: forSameURI.length ? FileType.File : FileType.Directory,
162
};
163
}
164
165
public async readdir(resource: URI): Promise<[string, FileType][]> {
166
const { forSameURI, contents, resourceURI } = await this._readURI(resource);
167
if (forSameURI.length > 0) {
168
throw createFileSystemProviderError(`File is not a directory`, FileSystemProviderErrorCode.FileNotADirectory);
169
}
170
const resourcePathParts = resourceURI.pathname.split('/');
171
172
const output = new Map<string, FileType>();
173
for (const content of contents) {
174
const contentURI = URI.parse(content.uri);
175
const contentPathParts = contentURI.path.split('/');
176
177
// Skip contents that are not in the same directory
178
if (contentPathParts.length <= resourcePathParts.length || !resourcePathParts.every((part, index) => equalsIgnoreCase(part, contentPathParts[index]))) {
179
continue;
180
}
181
182
// nested resource in a directory, just emit a directory to output
183
else if (contentPathParts.length > resourcePathParts.length + 1) {
184
output.set(contentPathParts[resourcePathParts.length], FileType.Directory);
185
}
186
187
else {
188
// resource in the same directory, emit the file
189
const name = contentPathParts[contentPathParts.length - 1];
190
output.set(name, contentToBuffer(content).byteLength > 0 ? FileType.File : FileType.Directory);
191
}
192
}
193
194
return [...output];
195
}
196
197
public mkdir(resource: URI): Promise<void> {
198
throw createFileSystemProviderError('write is not supported', FileSystemProviderErrorCode.NoPermissions);
199
}
200
public writeFile(resource: URI, content: Uint8Array, opts: IFileWriteOptions): Promise<void> {
201
throw createFileSystemProviderError('write is not supported', FileSystemProviderErrorCode.NoPermissions);
202
}
203
public delete(resource: URI, opts: IFileDeleteOptions): Promise<void> {
204
throw createFileSystemProviderError('delete is not supported', FileSystemProviderErrorCode.NoPermissions);
205
}
206
public rename(from: URI, to: URI, opts: IFileOverwriteOptions): Promise<void> {
207
throw createFileSystemProviderError('rename is not supported', FileSystemProviderErrorCode.NoPermissions);
208
}
209
210
//#endregion
211
212
private async _readFile(resource: URI, token?: CancellationToken): Promise<Uint8Array> {
213
const { forSameURI, contents } = await this._readURI(resource);
214
215
// MCP does not distinguish between files and directories, and says that
216
// servers should just return multiple when 'reading' a directory.
217
if (!forSameURI.length) {
218
if (!contents.length) {
219
throw createFileSystemProviderError(`File not found`, FileSystemProviderErrorCode.FileNotFound);
220
} else {
221
throw createFileSystemProviderError(`File is a directory`, FileSystemProviderErrorCode.FileIsADirectory);
222
}
223
}
224
225
return contentToBuffer(forSameURI[0]);
226
}
227
228
private _decodeURI(uri: URI) {
229
let definitionId: string;
230
let resourceURL: URL;
231
try {
232
({ definitionId, resourceURL } = McpResourceURI.toServer(uri));
233
} catch (e) {
234
throw createFileSystemProviderError(String(e), FileSystemProviderErrorCode.FileNotFound);
235
}
236
237
if (resourceURL.pathname.endsWith('/')) {
238
resourceURL.pathname = resourceURL.pathname.slice(0, -1);
239
}
240
241
const server = this._mcpService.servers.get().find(s => s.definition.id === definitionId);
242
if (!server) {
243
throw createFileSystemProviderError(`MCP server ${definitionId} not found`, FileSystemProviderErrorCode.FileNotFound);
244
}
245
246
const cap = server.capabilities.get();
247
if (cap !== undefined && !(cap & McpCapability.Resources)) {
248
throw createFileSystemProviderError(`MCP server ${definitionId} does not support resources`, FileSystemProviderErrorCode.FileNotFound);
249
}
250
251
return { definitionId, resourceURI: resourceURL, server };
252
}
253
254
private async _readURI(uri: URI, token?: CancellationToken) {
255
const cached = this._momentaryCache.get(uri);
256
if (cached) {
257
cached.pool.add(token || CancellationToken.None);
258
return cached.promise;
259
}
260
261
const pool = this._store.add(new CancellationTokenPool());
262
pool.add(token || CancellationToken.None);
263
264
const promise = this._readURIInner(uri, pool.token);
265
this._momentaryCache.set(uri, { pool, promise });
266
267
const disposable = this._store.add(disposableTimeout(() => {
268
this._momentaryCache.delete(uri);
269
this._store.delete(disposable);
270
this._store.delete(pool);
271
}, MOMENTARY_CACHE_DURATION));
272
273
return promise;
274
}
275
276
private async _readURIInner(uri: URI, token?: CancellationToken): Promise<IReadData> {
277
const { resourceURI, server } = this._decodeURI(uri);
278
const matchedServer = this._mcpService.servers.get().find(s => s.definition.id === server.definition.id);
279
280
//check for http/https resources and use web content extractor service to fetch the contents.
281
if (canLoadMcpNetworkResourceDirectly(resourceURI, matchedServer)) {
282
const extractURI = URI.parse(resourceURI.toString());
283
const result = (await this._webContentExtractorService.extract([extractURI], { followRedirects: false })).at(0);
284
if (result?.status === 'ok') {
285
return {
286
contents: [{ uri: resourceURI.toString(), text: result.result }],
287
resourceURI,
288
forSameURI: [{ uri: resourceURI.toString(), text: result.result }]
289
};
290
}
291
}
292
293
const res = await McpServer.callOn(server, r => r.readResource({ uri: resourceURI.toString() }, token), token);
294
return {
295
contents: res.contents,
296
resourceURI,
297
forSameURI: res.contents.filter(c => equalsUrlPath(c.uri, resourceURI))
298
};
299
}
300
}
301
302
function equalsUrlPath(a: string, b: URL): boolean {
303
// MCP doesn't specify either way, but underlying systems may can be case-sensitive.
304
// It's better to treat case-sensitive paths as case-insensitive than vise-versa.
305
return equalsIgnoreCase(new URL(a).pathname, b.pathname);
306
}
307
308
function contentToBuffer(content: MCP.TextResourceContents | MCP.BlobResourceContents): Uint8Array {
309
if ('text' in content) {
310
return VSBuffer.fromString(content.text).buffer;
311
} else if ('blob' in content) {
312
return decodeBase64(content.blob).buffer;
313
} else {
314
throw createFileSystemProviderError('Unknown content type', FileSystemProviderErrorCode.Unknown);
315
}
316
}
317
318