Path: blob/main/src/vs/workbench/contrib/mcp/test/common/mcpResourceFilesystem.test.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 * as assert from 'assert';6import { Barrier, timeout } from '../../../../../base/common/async.js';7import { URI } from '../../../../../base/common/uri.js';8import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';9import { NullCommandService } from '../../../../../platform/commands/test/common/nullCommandService.js';10import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';11import { FileChangeType, FileSystemProviderErrorCode, FileType, IFileChange, IFileService } from '../../../../../platform/files/common/files.js';12import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';13import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';14import { ILoggerService, NullLogService } from '../../../../../platform/log/common/log.js';15import { IProductService } from '../../../../../platform/product/common/productService.js';16import { IStorageService } from '../../../../../platform/storage/common/storage.js';17import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';18import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js';19import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';20import { TestContextService, TestLoggerService, TestProductService, TestStorageService } from '../../../../test/common/workbenchTestServices.js';21import { IMcpRegistry } from '../../common/mcpRegistryTypes.js';22import { McpResourceFilesystem } from '../../common/mcpResourceFilesystem.js';23import { McpService } from '../../common/mcpService.js';24import { IMcpService } from '../../common/mcpTypes.js';25import { MCP } from '../../common/modelContextProtocol.js';26import { TestMcpMessageTransport, TestMcpRegistry } from './mcpRegistryTypes.js';272829suite('Workbench - MCP - ResourceFilesystem', () => {3031const ds = ensureNoDisposablesAreLeakedInTestSuite();3233let transport: TestMcpMessageTransport;34let fs: McpResourceFilesystem;3536setup(() => {37const services = new ServiceCollection(38[IFileService, { registerProvider: () => { } }],39[IStorageService, ds.add(new TestStorageService())],40[ILoggerService, ds.add(new TestLoggerService())],41[IWorkspaceContextService, new TestContextService()],42[ITelemetryService, NullTelemetryService],43[IProductService, TestProductService],44);4546const parentInsta1 = ds.add(new TestInstantiationService(services));47const registry = new TestMcpRegistry(parentInsta1);4849const parentInsta2 = ds.add(parentInsta1.createChild(new ServiceCollection([IMcpRegistry, registry])));50const mcpService = ds.add(new McpService(parentInsta2, registry, new NullLogService(), {} as any, NullCommandService, new TestConfigurationService()));51mcpService.updateCollectedServers();5253const instaService = ds.add(parentInsta2.createChild(new ServiceCollection(54[IMcpRegistry, registry],55[IMcpService, mcpService],56)));5758fs = ds.add(instaService.createInstance(McpResourceFilesystem));5960transport = ds.add(new TestMcpMessageTransport());61registry.makeTestTransport = () => transport;62});6364test('reads a basic file', async () => {65transport.setResponder('resources/read', msg => {66assert.strictEqual(msg.params.uri, 'custom://hello/world.txt');67return {68id: msg.id,69jsonrpc: '2.0',70result: {71contents: [{ uri: msg.params.uri, text: 'Hello World' }],72} satisfies MCP.ReadResourceResult73};74});7576const response = await fs.readFile(URI.parse('mcp-resource://746573742D736572766572/custom/hello/world.txt'));77assert.strictEqual(new TextDecoder().decode(response), 'Hello World');78});7980test('stat returns file information', async () => {81transport.setResponder('resources/read', msg => {82assert.strictEqual(msg.params.uri, 'custom://hello/world.txt');83return {84id: msg.id,85jsonrpc: '2.0',86result: {87contents: [{ uri: msg.params.uri, text: 'Hello World' }],88} satisfies MCP.ReadResourceResult89};90});9192const fileStats = await fs.stat(URI.parse('mcp-resource://746573742D736572766572/custom/hello/world.txt'));93assert.strictEqual(fileStats.type, FileType.File);94assert.strictEqual(fileStats.size, 'Hello World'.length);95});9697test('stat returns directory information', async () => {98transport.setResponder('resources/read', msg => {99assert.strictEqual(msg.params.uri, 'custom://hello');100return {101id: msg.id,102jsonrpc: '2.0',103result: {104contents: [105{ uri: 'custom://hello/file1.txt', text: 'File 1' },106{ uri: 'custom://hello/file2.txt', text: 'File 2' },107],108} satisfies MCP.ReadResourceResult109};110});111112const dirStats = await fs.stat(URI.parse('mcp-resource://746573742D736572766572/custom/hello/'));113assert.strictEqual(dirStats.type, FileType.Directory);114// Size should be sum of all file contents in the directory115assert.strictEqual(dirStats.size, 'File 1'.length + 'File 2'.length);116});117118test('stat throws FileNotFound for nonexistent resources', async () => {119transport.setResponder('resources/read', msg => {120return {121id: msg.id,122jsonrpc: '2.0',123result: {124contents: [],125} satisfies MCP.ReadResourceResult126};127});128129await assert.rejects(130() => fs.stat(URI.parse('mcp-resource://746573742D736572766572/custom/nonexistent.txt')),131(err: any) => err.code === FileSystemProviderErrorCode.FileNotFound132);133});134135test('readdir returns directory contents', async () => {136transport.setResponder('resources/read', msg => {137assert.strictEqual(msg.params.uri, 'custom://hello/dir');138return {139id: msg.id,140jsonrpc: '2.0',141result: {142contents: [143{ uri: 'custom://hello/dir/file1.txt', text: 'File 1' },144{ uri: 'custom://hello/dir/file2.txt', text: 'File 2' },145{ uri: 'custom://hello/dir/subdir/file3.txt', text: 'File 3' },146],147} satisfies MCP.ReadResourceResult148};149});150151const dirEntries = await fs.readdir(URI.parse('mcp-resource://746573742D736572766572/custom/hello/dir/'));152assert.deepStrictEqual(dirEntries, [153['file1.txt', FileType.File],154['file2.txt', FileType.File],155['subdir', FileType.Directory],156]);157});158159test('readdir throws when reading a file as directory', async () => {160transport.setResponder('resources/read', msg => {161return {162id: msg.id,163jsonrpc: '2.0',164result: {165contents: [{ uri: msg.params.uri, text: 'This is a file' }],166} satisfies MCP.ReadResourceResult167};168});169170await assert.rejects(171() => fs.readdir(URI.parse('mcp-resource://746573742D736572766572/custom/hello/file.txt')),172(err: any) => err.code === FileSystemProviderErrorCode.FileNotADirectory173);174});175176test('watch file emits change events', async () => {177// Set up the responder for resource reading178transport.setResponder('resources/read', msg => {179return {180id: msg.id,181jsonrpc: '2.0',182result: {183contents: [{ uri: msg.params.uri, text: 'File content' }],184} satisfies MCP.ReadResourceResult185};186});187188const didSubscribe = new Barrier();189190// Set up the responder for resource subscription191transport.setResponder('resources/subscribe', msg => {192didSubscribe.open();193return {194id: msg.id,195jsonrpc: '2.0',196result: {},197};198});199200const uri = URI.parse('mcp-resource://746573742D736572766572/custom/hello/file.txt');201const fileChanges: IFileChange[] = [];202203// Create a listener for file change events204const disposable = fs.onDidChangeFile(events => {205fileChanges.push(...events);206});207208// Start watching the file209const watchDisposable = fs.watch(uri, { excludes: [], recursive: false });210211// Simulate a file update notification from the server212await didSubscribe.wait();213await timeout(10); // wait for listeners to attach214215transport.simulateReceiveMessage({216jsonrpc: '2.0',217method: 'notifications/resources/updated',218params: {219uri: 'custom://hello/file.txt',220},221});222transport.simulateReceiveMessage({223jsonrpc: '2.0',224method: 'notifications/resources/updated',225params: {226uri: 'custom://hello/unrelated.txt',227},228});229230// Check that we received a file change event231assert.strictEqual(fileChanges.length, 1);232assert.strictEqual(fileChanges[0].type, FileChangeType.UPDATED);233assert.strictEqual(fileChanges[0].resource.toString(), uri.toString());234235// Clean up236disposable.dispose();237watchDisposable.dispose();238});239240test('read blob resource', async () => {241const blobBase64 = 'SGVsbG8gV29ybGQgYXMgQmxvYg=='; // "Hello World as Blob" in base64242243transport.setResponder('resources/read', msg => {244assert.strictEqual(msg.params.uri, 'custom://hello/blob.bin');245return {246id: msg.id,247jsonrpc: '2.0',248result: {249contents: [{ uri: msg.params.uri, blob: blobBase64 }],250} satisfies MCP.ReadResourceResult251};252});253254const response = await fs.readFile(URI.parse('mcp-resource://746573742D736572766572/custom/hello/blob.bin'));255assert.strictEqual(new TextDecoder().decode(response), 'Hello World as Blob');256});257258test('throws error for write operations', async () => {259const uri = URI.parse('mcp-resource://746573742D736572766572/custom/hello/file.txt');260261await assert.rejects(262async () => fs.writeFile(uri, new Uint8Array(), { create: true, overwrite: true, atomic: false, unlock: false }),263(err: any) => err.code === FileSystemProviderErrorCode.NoPermissions264);265266await assert.rejects(267async () => fs.delete(uri, { recursive: false, useTrash: false, atomic: false }),268(err: any) => err.code === FileSystemProviderErrorCode.NoPermissions269);270271await assert.rejects(272async () => fs.mkdir(uri),273(err: any) => err.code === FileSystemProviderErrorCode.NoPermissions274);275276await assert.rejects(277async () => fs.rename(uri, URI.parse('mcp-resource://746573742D736572766572/custom/hello/newfile.txt'), { overwrite: false }),278(err: any) => err.code === FileSystemProviderErrorCode.NoPermissions279);280});281});282283284