Path: blob/main/src/vs/workbench/contrib/mcp/test/common/mcpResourceFilesystem.test.ts
5263 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 { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';10import { FileChangeType, FileSystemProviderErrorCode, FileType, IFileChange, IFileService, toFileSystemProviderErrorCode } from '../../../../../platform/files/common/files.js';11import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';12import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';13import { ILoggerService, NullLogService } from '../../../../../platform/log/common/log.js';14import { IProductService } from '../../../../../platform/product/common/productService.js';15import { IStorageService } from '../../../../../platform/storage/common/storage.js';16import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';17import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js';18import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';19import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.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[IWorkbenchEnvironmentService, {}],43[ITelemetryService, NullTelemetryService],44[IProductService, TestProductService],45);4647const parentInsta1 = ds.add(new TestInstantiationService(services));48const registry = new TestMcpRegistry(parentInsta1);4950const parentInsta2 = ds.add(parentInsta1.createChild(new ServiceCollection([IMcpRegistry, registry])));51const mcpService = ds.add(new McpService(parentInsta2, registry, new NullLogService(), new TestConfigurationService()));52mcpService.updateCollectedServers();5354const instaService = ds.add(parentInsta2.createChild(new ServiceCollection(55[IMcpRegistry, registry],56[IMcpService, mcpService],57)));5859fs = ds.add(instaService.createInstance(McpResourceFilesystem));6061transport = ds.add(new TestMcpMessageTransport());62registry.makeTestTransport = () => transport;63});6465test('reads a basic file', async () => {66transport.setResponder('resources/read', msg => {67const request = msg as { id: string | number; params: { uri: string } };68assert.strictEqual(request.params.uri, 'custom://hello/world.txt');69return {70id: request.id,71jsonrpc: '2.0',72result: {73contents: [{ uri: request.params.uri, text: 'Hello World' }],74} satisfies MCP.ReadResourceResult75};76});7778const response = await fs.readFile(URI.parse('mcp-resource://746573742D736572766572/custom/hello/world.txt'));79assert.strictEqual(new TextDecoder().decode(response), 'Hello World');80});8182test('stat returns file information', async () => {83transport.setResponder('resources/read', msg => {84const request = msg as { id: string | number; params: { uri: string } };85assert.strictEqual(request.params.uri, 'custom://hello/world.txt');86return {87id: request.id,88jsonrpc: '2.0',89result: {90contents: [{ uri: request.params.uri, text: 'Hello World' }],91} satisfies MCP.ReadResourceResult92};93});9495const fileStats = await fs.stat(URI.parse('mcp-resource://746573742D736572766572/custom/hello/world.txt'));96assert.strictEqual(fileStats.type, FileType.File);97assert.strictEqual(fileStats.size, 'Hello World'.length);98});99100test('stat returns directory information', async () => {101transport.setResponder('resources/read', msg => {102const request = msg as { id: string | number; params: { uri: string } };103assert.strictEqual(request.params.uri, 'custom://hello');104return {105id: request.id,106jsonrpc: '2.0',107result: {108contents: [109{ uri: 'custom://hello/file1.txt', text: 'File 1' },110{ uri: 'custom://hello/file2.txt', text: 'File 2' },111],112} satisfies MCP.ReadResourceResult113};114});115116const dirStats = await fs.stat(URI.parse('mcp-resource://746573742D736572766572/custom/hello/'));117assert.strictEqual(dirStats.type, FileType.Directory);118// Size should be sum of all file contents in the directory119assert.strictEqual(dirStats.size, 'File 1'.length + 'File 2'.length);120});121122test('stat throws FileNotFound for nonexistent resources', async () => {123transport.setResponder('resources/read', msg => {124const request = msg as { id: string | number };125return {126id: request.id,127jsonrpc: '2.0',128result: {129contents: [],130} satisfies MCP.ReadResourceResult131};132});133134await assert.rejects(135() => fs.stat(URI.parse('mcp-resource://746573742D736572766572/custom/nonexistent.txt')),136(err: Error) => toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.FileNotFound137);138});139140test('readdir returns directory contents', async () => {141transport.setResponder('resources/read', msg => {142const request = msg as { id: string | number; params: { uri: string } };143assert.strictEqual(request.params.uri, 'custom://hello/dir');144return {145id: request.id,146jsonrpc: '2.0',147result: {148contents: [149{ uri: 'custom://hello/dir/file1.txt', text: 'File 1' },150{ uri: 'custom://hello/dir/file2.txt', text: 'File 2' },151{ uri: 'custom://hello/dir/subdir/file3.txt', text: 'File 3' },152],153} satisfies MCP.ReadResourceResult154};155});156157const dirEntries = await fs.readdir(URI.parse('mcp-resource://746573742D736572766572/custom/hello/dir/'));158assert.deepStrictEqual(dirEntries, [159['file1.txt', FileType.File],160['file2.txt', FileType.File],161['subdir', FileType.Directory],162]);163});164165test('readdir throws when reading a file as directory', async () => {166transport.setResponder('resources/read', msg => {167const request = msg as { id: string | number; params: { uri: string } };168return {169id: request.id,170jsonrpc: '2.0',171result: {172contents: [{ uri: request.params.uri, text: 'This is a file' }],173} satisfies MCP.ReadResourceResult174};175});176177await assert.rejects(178() => fs.readdir(URI.parse('mcp-resource://746573742D736572766572/custom/hello/file.txt')),179(err: Error) => toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.FileNotADirectory180);181});182183test('watch file emits change events', async () => {184// Set up the responder for resource reading185transport.setResponder('resources/read', msg => {186const request = msg as { id: string | number; params: { uri: string } };187return {188id: request.id,189jsonrpc: '2.0',190result: {191contents: [{ uri: request.params.uri, text: 'File content' }],192} satisfies MCP.ReadResourceResult193};194});195196const didSubscribe = new Barrier();197198// Set up the responder for resource subscription199transport.setResponder('resources/subscribe', msg => {200const request = msg as { id: string | number };201didSubscribe.open();202return {203id: request.id,204jsonrpc: '2.0',205result: {},206};207});208209const uri = URI.parse('mcp-resource://746573742D736572766572/custom/hello/file.txt');210const fileChanges: IFileChange[] = [];211212// Create a listener for file change events213const disposable = fs.onDidChangeFile(events => {214fileChanges.push(...events);215});216217// Start watching the file218const watchDisposable = fs.watch(uri, { excludes: [], recursive: false });219220// Simulate a file update notification from the server221await didSubscribe.wait();222await timeout(10); // wait for listeners to attach223224transport.simulateReceiveMessage({225jsonrpc: '2.0',226method: 'notifications/resources/updated',227params: {228uri: 'custom://hello/file.txt',229},230});231transport.simulateReceiveMessage({232jsonrpc: '2.0',233method: 'notifications/resources/updated',234params: {235uri: 'custom://hello/unrelated.txt',236},237});238239// Check that we received a file change event240assert.strictEqual(fileChanges.length, 1);241assert.strictEqual(fileChanges[0].type, FileChangeType.UPDATED);242assert.strictEqual(fileChanges[0].resource.toString(), uri.toString());243244// Clean up245disposable.dispose();246watchDisposable.dispose();247});248249test('read blob resource', async () => {250const blobBase64 = 'SGVsbG8gV29ybGQgYXMgQmxvYg=='; // "Hello World as Blob" in base64251252transport.setResponder('resources/read', msg => {253const params = (msg as { id: string | number; params: { uri: string } });254assert.strictEqual(params.params.uri, 'custom://hello/blob.bin');255return {256id: params.id,257jsonrpc: '2.0',258result: {259contents: [{ uri: params.params.uri, blob: blobBase64 }],260} satisfies MCP.ReadResourceResult261};262});263264const response = await fs.readFile(URI.parse('mcp-resource://746573742D736572766572/custom/hello/blob.bin'));265assert.strictEqual(new TextDecoder().decode(response), 'Hello World as Blob');266});267268test('throws error for write operations', async () => {269const uri = URI.parse('mcp-resource://746573742D736572766572/custom/hello/file.txt');270271await assert.rejects(272async () => fs.writeFile(uri, new Uint8Array(), { create: true, overwrite: true, atomic: false, unlock: false }),273(err: Error) => toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.NoPermissions274);275276await assert.rejects(277async () => fs.delete(uri, { recursive: false, useTrash: false, atomic: false }),278(err: Error) => toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.NoPermissions279);280281await assert.rejects(282async () => fs.mkdir(uri),283(err: Error) => toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.NoPermissions284);285286await assert.rejects(287async () => fs.rename(uri, URI.parse('mcp-resource://746573742D736572766572/custom/hello/newfile.txt'), { overwrite: false }),288(err: Error) => toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.NoPermissions289);290});291});292293294