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
3296 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 { decodeBase64, VSBuffer } from '../../../../base/common/buffer.js';
8
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
9
import { Emitter, Event } from '../../../../base/common/event.js';
10
import { Lazy } from '../../../../base/common/lazy.js';
11
import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
12
import { autorun } from '../../../../base/common/observable.js';
13
import { newWriteableStream, ReadableStreamEvents } from '../../../../base/common/stream.js';
14
import { equalsIgnoreCase } from '../../../../base/common/strings.js';
15
import { URI } from '../../../../base/common/uri.js';
16
import { createFileSystemProviderError, FileChangeType, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileChange, IFileDeleteOptions, IFileOverwriteOptions, IFileReadStreamOptions, IFileService, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileWriteOptions, IStat, IWatchOptions } from '../../../../platform/files/common/files.js';
17
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
18
import { IWorkbenchContribution } from '../../../common/contributions.js';
19
import { McpServer } from './mcpServer.js';
20
import { McpServerRequestHandler } from './mcpServerRequestHandler.js';
21
import { IMcpService, McpCapability, McpResourceURI } from './mcpTypes.js';
22
import { MCP } from './modelContextProtocol.js';
23
24
export class McpResourceFilesystem extends Disposable implements IWorkbenchContribution,
25
IFileSystemProviderWithFileReadWriteCapability,
26
IFileSystemProviderWithFileAtomicReadCapability,
27
IFileSystemProviderWithFileReadStreamCapability {
28
/** Defer getting the MCP service since this is a BlockRestore and no need to make it unnecessarily. */
29
private readonly _mcpServiceLazy = new Lazy(() => this._instantiationService.invokeFunction(a => a.get(IMcpService)));
30
31
private get _mcpService() {
32
return this._mcpServiceLazy.value;
33
}
34
35
public readonly onDidChangeCapabilities = Event.None;
36
37
private readonly _onDidChangeFile = this._register(new Emitter<readonly IFileChange[]>());
38
public readonly onDidChangeFile = this._onDidChangeFile.event;
39
40
public readonly capabilities: FileSystemProviderCapabilities = FileSystemProviderCapabilities.None
41
| FileSystemProviderCapabilities.Readonly
42
| FileSystemProviderCapabilities.PathCaseSensitive
43
| FileSystemProviderCapabilities.FileReadStream
44
| FileSystemProviderCapabilities.FileAtomicRead
45
| FileSystemProviderCapabilities.FileReadWrite;
46
47
constructor(
48
@IInstantiationService private readonly _instantiationService: IInstantiationService,
49
@IFileService private readonly _fileService: IFileService,
50
) {
51
super();
52
this._register(this._fileService.registerProvider(McpResourceURI.scheme, this));
53
}
54
55
//#region Filesystem API
56
57
public async readFile(resource: URI): Promise<Uint8Array> {
58
return this._readFile(resource);
59
}
60
61
public readFileStream(resource: URI, opts: IFileReadStreamOptions, token: CancellationToken): ReadableStreamEvents<Uint8Array> {
62
const stream = newWriteableStream<Uint8Array>(data => VSBuffer.concat(data.map(data => VSBuffer.wrap(data))).buffer);
63
64
this._readFile(resource, token).then(
65
data => {
66
if (opts.position) {
67
data = data.slice(opts.position);
68
}
69
70
if (opts.length) {
71
data = data.slice(0, opts.length);
72
}
73
74
stream.end(data);
75
},
76
err => stream.error(err),
77
);
78
79
return stream;
80
}
81
82
public watch(uri: URI, _opts: IWatchOptions): IDisposable {
83
const { resourceURI, server } = this._decodeURI(uri);
84
const cap = server.capabilities.get();
85
if (cap !== undefined && !(cap & McpCapability.ResourcesSubscribe)) {
86
return Disposable.None;
87
}
88
89
server.start();
90
91
const store = new DisposableStore();
92
let watchedOnHandler: McpServerRequestHandler | undefined;
93
const watchListener = store.add(new MutableDisposable());
94
const callCts = store.add(new MutableDisposable<CancellationTokenSource>());
95
store.add(autorun(reader => {
96
const connection = server.connection.read(reader);
97
if (!connection) {
98
return;
99
}
100
101
const handler = connection.handler.read(reader);
102
if (!handler || watchedOnHandler === handler) {
103
return;
104
}
105
106
callCts.value?.dispose(true);
107
callCts.value = new CancellationTokenSource();
108
watchedOnHandler = handler;
109
110
const token = callCts.value.token;
111
handler.subscribe({ uri: resourceURI.toString() }, token).then(
112
() => {
113
if (!token.isCancellationRequested) {
114
watchListener.value = handler.onDidUpdateResource(e => {
115
if (equalsUrlPath(e.params.uri, resourceURI)) {
116
this._onDidChangeFile.fire([{ resource: uri, type: FileChangeType.UPDATED }]);
117
}
118
});
119
}
120
}, err => {
121
handler.logger.warn(`Failed to subscribe to resource changes for ${resourceURI}: ${err}`);
122
watchedOnHandler = undefined;
123
},
124
);
125
}));
126
127
return store;
128
}
129
130
public async stat(resource: URI): Promise<IStat> {
131
const { forSameURI, contents } = await this._readURI(resource);
132
if (!contents.length) {
133
throw createFileSystemProviderError(`File not found`, FileSystemProviderErrorCode.FileNotFound);
134
}
135
136
return {
137
ctime: 0,
138
mtime: 0,
139
size: sumBy(contents, c => contentToBuffer(c).byteLength),
140
type: forSameURI.length ? FileType.File : FileType.Directory,
141
};
142
}
143
144
public async readdir(resource: URI): Promise<[string, FileType][]> {
145
const { forSameURI, contents, resourceURI } = await this._readURI(resource);
146
if (forSameURI.length > 0) {
147
throw createFileSystemProviderError(`File is not a directory`, FileSystemProviderErrorCode.FileNotADirectory);
148
}
149
150
const resourcePathParts = resourceURI.pathname.split('/');
151
152
const output = new Map<string, FileType>();
153
for (const content of contents) {
154
const contentURI = URI.parse(content.uri);
155
const contentPathParts = contentURI.path.split('/');
156
157
// Skip contents that are not in the same directory
158
if (contentPathParts.length <= resourcePathParts.length || !resourcePathParts.every((part, index) => equalsIgnoreCase(part, contentPathParts[index]))) {
159
continue;
160
}
161
162
// nested resource in a directory, just emit a directory to output
163
else if (contentPathParts.length > resourcePathParts.length + 1) {
164
output.set(contentPathParts[resourcePathParts.length], FileType.Directory);
165
}
166
167
else {
168
// resource in the same directory, emit the file
169
const name = contentPathParts[contentPathParts.length - 1];
170
output.set(name, contentToBuffer(content).byteLength > 0 ? FileType.File : FileType.Directory);
171
}
172
}
173
174
return [...output];
175
}
176
177
public mkdir(resource: URI): Promise<void> {
178
throw createFileSystemProviderError('write is not supported', FileSystemProviderErrorCode.NoPermissions);
179
}
180
public writeFile(resource: URI, content: Uint8Array, opts: IFileWriteOptions): Promise<void> {
181
throw createFileSystemProviderError('write is not supported', FileSystemProviderErrorCode.NoPermissions);
182
}
183
public delete(resource: URI, opts: IFileDeleteOptions): Promise<void> {
184
throw createFileSystemProviderError('delete is not supported', FileSystemProviderErrorCode.NoPermissions);
185
}
186
public rename(from: URI, to: URI, opts: IFileOverwriteOptions): Promise<void> {
187
throw createFileSystemProviderError('rename is not supported', FileSystemProviderErrorCode.NoPermissions);
188
}
189
190
//#endregion
191
192
private async _readFile(resource: URI, token?: CancellationToken): Promise<Uint8Array> {
193
const { forSameURI, contents } = await this._readURI(resource);
194
195
// MCP does not distinguish between files and directories, and says that
196
// servers should just return multiple when 'reading' a directory.
197
if (!forSameURI.length) {
198
if (!contents.length) {
199
throw createFileSystemProviderError(`File not found`, FileSystemProviderErrorCode.FileNotFound);
200
} else {
201
throw createFileSystemProviderError(`File is a directory`, FileSystemProviderErrorCode.FileIsADirectory);
202
}
203
}
204
205
return contentToBuffer(forSameURI[0]);
206
}
207
208
private _decodeURI(uri: URI) {
209
let definitionId: string;
210
let resourceURL: URL;
211
try {
212
({ definitionId, resourceURL } = McpResourceURI.toServer(uri));
213
} catch (e) {
214
throw createFileSystemProviderError(String(e), FileSystemProviderErrorCode.FileNotFound);
215
}
216
217
if (resourceURL.pathname.endsWith('/')) {
218
resourceURL.pathname = resourceURL.pathname.slice(0, -1);
219
}
220
221
const server = this._mcpService.servers.get().find(s => s.definition.id === definitionId);
222
if (!server) {
223
throw createFileSystemProviderError(`MCP server ${definitionId} not found`, FileSystemProviderErrorCode.FileNotFound);
224
}
225
226
const cap = server.capabilities.get();
227
if (cap !== undefined && !(cap & McpCapability.Resources)) {
228
throw createFileSystemProviderError(`MCP server ${definitionId} does not support resources`, FileSystemProviderErrorCode.FileNotFound);
229
}
230
231
return { definitionId, resourceURI: resourceURL, server };
232
}
233
234
private async _readURI(uri: URI, token?: CancellationToken) {
235
const { resourceURI, server } = this._decodeURI(uri);
236
const res = await McpServer.callOn(server, r => r.readResource({ uri: resourceURI.toString() }, token), token);
237
238
return {
239
contents: res.contents,
240
resourceURI,
241
forSameURI: res.contents.filter(c => equalsUrlPath(c.uri, resourceURI)),
242
};
243
}
244
}
245
246
function equalsUrlPath(a: string, b: URL): boolean {
247
// MCP doesn't specify either way, but underlying systems may can be case-sensitive.
248
// It's better to treat case-sensitive paths as case-insensitive than vise-versa.
249
return equalsIgnoreCase(new URL(a).pathname, b.pathname);
250
}
251
252
function contentToBuffer(content: MCP.TextResourceContents | MCP.BlobResourceContents): Uint8Array {
253
if ('text' in content) {
254
return VSBuffer.fromString(content.text).buffer;
255
} else if ('blob' in content) {
256
return decodeBase64(content.blob).buffer;
257
} else {
258
throw createFileSystemProviderError('Unknown content type', FileSystemProviderErrorCode.Unknown);
259
}
260
}
261
262