Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/mcp/test/common/mcpResourceFilesystem.test.ts
5263 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 * as assert from 'assert';
7
import { Barrier, timeout } from '../../../../../base/common/async.js';
8
import { URI } from '../../../../../base/common/uri.js';
9
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
10
import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';
11
import { FileChangeType, FileSystemProviderErrorCode, FileType, IFileChange, IFileService, toFileSystemProviderErrorCode } from '../../../../../platform/files/common/files.js';
12
import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';
13
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
14
import { ILoggerService, NullLogService } from '../../../../../platform/log/common/log.js';
15
import { IProductService } from '../../../../../platform/product/common/productService.js';
16
import { IStorageService } from '../../../../../platform/storage/common/storage.js';
17
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
18
import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js';
19
import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';
20
import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js';
21
import { TestContextService, TestLoggerService, TestProductService, TestStorageService } from '../../../../test/common/workbenchTestServices.js';
22
import { IMcpRegistry } from '../../common/mcpRegistryTypes.js';
23
import { McpResourceFilesystem } from '../../common/mcpResourceFilesystem.js';
24
import { McpService } from '../../common/mcpService.js';
25
import { IMcpService } from '../../common/mcpTypes.js';
26
import { MCP } from '../../common/modelContextProtocol.js';
27
import { TestMcpMessageTransport, TestMcpRegistry } from './mcpRegistryTypes.js';
28
29
30
suite('Workbench - MCP - ResourceFilesystem', () => {
31
32
const ds = ensureNoDisposablesAreLeakedInTestSuite();
33
34
let transport: TestMcpMessageTransport;
35
let fs: McpResourceFilesystem;
36
37
setup(() => {
38
const services = new ServiceCollection(
39
[IFileService, { registerProvider: () => { } }],
40
[IStorageService, ds.add(new TestStorageService())],
41
[ILoggerService, ds.add(new TestLoggerService())],
42
[IWorkspaceContextService, new TestContextService()],
43
[IWorkbenchEnvironmentService, {}],
44
[ITelemetryService, NullTelemetryService],
45
[IProductService, TestProductService],
46
);
47
48
const parentInsta1 = ds.add(new TestInstantiationService(services));
49
const registry = new TestMcpRegistry(parentInsta1);
50
51
const parentInsta2 = ds.add(parentInsta1.createChild(new ServiceCollection([IMcpRegistry, registry])));
52
const mcpService = ds.add(new McpService(parentInsta2, registry, new NullLogService(), new TestConfigurationService()));
53
mcpService.updateCollectedServers();
54
55
const instaService = ds.add(parentInsta2.createChild(new ServiceCollection(
56
[IMcpRegistry, registry],
57
[IMcpService, mcpService],
58
)));
59
60
fs = ds.add(instaService.createInstance(McpResourceFilesystem));
61
62
transport = ds.add(new TestMcpMessageTransport());
63
registry.makeTestTransport = () => transport;
64
});
65
66
test('reads a basic file', async () => {
67
transport.setResponder('resources/read', msg => {
68
const request = msg as { id: string | number; params: { uri: string } };
69
assert.strictEqual(request.params.uri, 'custom://hello/world.txt');
70
return {
71
id: request.id,
72
jsonrpc: '2.0',
73
result: {
74
contents: [{ uri: request.params.uri, text: 'Hello World' }],
75
} satisfies MCP.ReadResourceResult
76
};
77
});
78
79
const response = await fs.readFile(URI.parse('mcp-resource://746573742D736572766572/custom/hello/world.txt'));
80
assert.strictEqual(new TextDecoder().decode(response), 'Hello World');
81
});
82
83
test('stat returns file information', async () => {
84
transport.setResponder('resources/read', msg => {
85
const request = msg as { id: string | number; params: { uri: string } };
86
assert.strictEqual(request.params.uri, 'custom://hello/world.txt');
87
return {
88
id: request.id,
89
jsonrpc: '2.0',
90
result: {
91
contents: [{ uri: request.params.uri, text: 'Hello World' }],
92
} satisfies MCP.ReadResourceResult
93
};
94
});
95
96
const fileStats = await fs.stat(URI.parse('mcp-resource://746573742D736572766572/custom/hello/world.txt'));
97
assert.strictEqual(fileStats.type, FileType.File);
98
assert.strictEqual(fileStats.size, 'Hello World'.length);
99
});
100
101
test('stat returns directory information', async () => {
102
transport.setResponder('resources/read', msg => {
103
const request = msg as { id: string | number; params: { uri: string } };
104
assert.strictEqual(request.params.uri, 'custom://hello');
105
return {
106
id: request.id,
107
jsonrpc: '2.0',
108
result: {
109
contents: [
110
{ uri: 'custom://hello/file1.txt', text: 'File 1' },
111
{ uri: 'custom://hello/file2.txt', text: 'File 2' },
112
],
113
} satisfies MCP.ReadResourceResult
114
};
115
});
116
117
const dirStats = await fs.stat(URI.parse('mcp-resource://746573742D736572766572/custom/hello/'));
118
assert.strictEqual(dirStats.type, FileType.Directory);
119
// Size should be sum of all file contents in the directory
120
assert.strictEqual(dirStats.size, 'File 1'.length + 'File 2'.length);
121
});
122
123
test('stat throws FileNotFound for nonexistent resources', async () => {
124
transport.setResponder('resources/read', msg => {
125
const request = msg as { id: string | number };
126
return {
127
id: request.id,
128
jsonrpc: '2.0',
129
result: {
130
contents: [],
131
} satisfies MCP.ReadResourceResult
132
};
133
});
134
135
await assert.rejects(
136
() => fs.stat(URI.parse('mcp-resource://746573742D736572766572/custom/nonexistent.txt')),
137
(err: Error) => toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.FileNotFound
138
);
139
});
140
141
test('readdir returns directory contents', async () => {
142
transport.setResponder('resources/read', msg => {
143
const request = msg as { id: string | number; params: { uri: string } };
144
assert.strictEqual(request.params.uri, 'custom://hello/dir');
145
return {
146
id: request.id,
147
jsonrpc: '2.0',
148
result: {
149
contents: [
150
{ uri: 'custom://hello/dir/file1.txt', text: 'File 1' },
151
{ uri: 'custom://hello/dir/file2.txt', text: 'File 2' },
152
{ uri: 'custom://hello/dir/subdir/file3.txt', text: 'File 3' },
153
],
154
} satisfies MCP.ReadResourceResult
155
};
156
});
157
158
const dirEntries = await fs.readdir(URI.parse('mcp-resource://746573742D736572766572/custom/hello/dir/'));
159
assert.deepStrictEqual(dirEntries, [
160
['file1.txt', FileType.File],
161
['file2.txt', FileType.File],
162
['subdir', FileType.Directory],
163
]);
164
});
165
166
test('readdir throws when reading a file as directory', async () => {
167
transport.setResponder('resources/read', msg => {
168
const request = msg as { id: string | number; params: { uri: string } };
169
return {
170
id: request.id,
171
jsonrpc: '2.0',
172
result: {
173
contents: [{ uri: request.params.uri, text: 'This is a file' }],
174
} satisfies MCP.ReadResourceResult
175
};
176
});
177
178
await assert.rejects(
179
() => fs.readdir(URI.parse('mcp-resource://746573742D736572766572/custom/hello/file.txt')),
180
(err: Error) => toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.FileNotADirectory
181
);
182
});
183
184
test('watch file emits change events', async () => {
185
// Set up the responder for resource reading
186
transport.setResponder('resources/read', msg => {
187
const request = msg as { id: string | number; params: { uri: string } };
188
return {
189
id: request.id,
190
jsonrpc: '2.0',
191
result: {
192
contents: [{ uri: request.params.uri, text: 'File content' }],
193
} satisfies MCP.ReadResourceResult
194
};
195
});
196
197
const didSubscribe = new Barrier();
198
199
// Set up the responder for resource subscription
200
transport.setResponder('resources/subscribe', msg => {
201
const request = msg as { id: string | number };
202
didSubscribe.open();
203
return {
204
id: request.id,
205
jsonrpc: '2.0',
206
result: {},
207
};
208
});
209
210
const uri = URI.parse('mcp-resource://746573742D736572766572/custom/hello/file.txt');
211
const fileChanges: IFileChange[] = [];
212
213
// Create a listener for file change events
214
const disposable = fs.onDidChangeFile(events => {
215
fileChanges.push(...events);
216
});
217
218
// Start watching the file
219
const watchDisposable = fs.watch(uri, { excludes: [], recursive: false });
220
221
// Simulate a file update notification from the server
222
await didSubscribe.wait();
223
await timeout(10); // wait for listeners to attach
224
225
transport.simulateReceiveMessage({
226
jsonrpc: '2.0',
227
method: 'notifications/resources/updated',
228
params: {
229
uri: 'custom://hello/file.txt',
230
},
231
});
232
transport.simulateReceiveMessage({
233
jsonrpc: '2.0',
234
method: 'notifications/resources/updated',
235
params: {
236
uri: 'custom://hello/unrelated.txt',
237
},
238
});
239
240
// Check that we received a file change event
241
assert.strictEqual(fileChanges.length, 1);
242
assert.strictEqual(fileChanges[0].type, FileChangeType.UPDATED);
243
assert.strictEqual(fileChanges[0].resource.toString(), uri.toString());
244
245
// Clean up
246
disposable.dispose();
247
watchDisposable.dispose();
248
});
249
250
test('read blob resource', async () => {
251
const blobBase64 = 'SGVsbG8gV29ybGQgYXMgQmxvYg=='; // "Hello World as Blob" in base64
252
253
transport.setResponder('resources/read', msg => {
254
const params = (msg as { id: string | number; params: { uri: string } });
255
assert.strictEqual(params.params.uri, 'custom://hello/blob.bin');
256
return {
257
id: params.id,
258
jsonrpc: '2.0',
259
result: {
260
contents: [{ uri: params.params.uri, blob: blobBase64 }],
261
} satisfies MCP.ReadResourceResult
262
};
263
});
264
265
const response = await fs.readFile(URI.parse('mcp-resource://746573742D736572766572/custom/hello/blob.bin'));
266
assert.strictEqual(new TextDecoder().decode(response), 'Hello World as Blob');
267
});
268
269
test('throws error for write operations', async () => {
270
const uri = URI.parse('mcp-resource://746573742D736572766572/custom/hello/file.txt');
271
272
await assert.rejects(
273
async () => fs.writeFile(uri, new Uint8Array(), { create: true, overwrite: true, atomic: false, unlock: false }),
274
(err: Error) => toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.NoPermissions
275
);
276
277
await assert.rejects(
278
async () => fs.delete(uri, { recursive: false, useTrash: false, atomic: false }),
279
(err: Error) => toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.NoPermissions
280
);
281
282
await assert.rejects(
283
async () => fs.mkdir(uri),
284
(err: Error) => toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.NoPermissions
285
);
286
287
await assert.rejects(
288
async () => fs.rename(uri, URI.parse('mcp-resource://746573742D736572766572/custom/hello/newfile.txt'), { overwrite: false }),
289
(err: Error) => toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.NoPermissions
290
);
291
});
292
});
293
294