Path: blob/main/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.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 { upcast } from '../../../../../base/common/types.js';7import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';8import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';9import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';10import { ILoggerService } from '../../../../../platform/log/common/log.js';11import { IProductService } from '../../../../../platform/product/common/productService.js';12import { IStorageService } from '../../../../../platform/storage/common/storage.js';13import { TestLoggerService, TestProductService, TestStorageService } from '../../../../test/common/workbenchTestServices.js';14import { IMcpHostDelegate } from '../../common/mcpRegistryTypes.js';15import { McpServerRequestHandler } from '../../common/mcpServerRequestHandler.js';16import { McpConnectionState } from '../../common/mcpTypes.js';17import { MCP } from '../../common/modelContextProtocol.js';18import { TestMcpMessageTransport } from './mcpRegistryTypes.js';19import { IOutputService } from '../../../../services/output/common/output.js';20import { Disposable } from '../../../../../base/common/lifecycle.js';21import { CancellationTokenSource } from '../../../../../base/common/cancellation.js';2223class TestMcpHostDelegate extends Disposable implements IMcpHostDelegate {24private readonly _transport: TestMcpMessageTransport;2526priority = 0;2728constructor() {29super();30this._transport = this._register(new TestMcpMessageTransport());31}3233canStart(): boolean {34return true;35}3637start(): TestMcpMessageTransport {38return this._transport;39}4041getTransport(): TestMcpMessageTransport {42return this._transport;43}4445waitForInitialProviderPromises(): Promise<void> {46return Promise.resolve();47}48}4950suite('Workbench - MCP - ServerRequestHandler', () => {51const store = ensureNoDisposablesAreLeakedInTestSuite();5253let instantiationService: TestInstantiationService;54let delegate: TestMcpHostDelegate;55let transport: TestMcpMessageTransport;56let handler: McpServerRequestHandler;57let cts: CancellationTokenSource;5859setup(async () => {60delegate = store.add(new TestMcpHostDelegate());61transport = delegate.getTransport();62cts = store.add(new CancellationTokenSource());6364// Setup test services65const services = new ServiceCollection(66[ILoggerService, store.add(new TestLoggerService())],67[IOutputService, upcast({ showChannel: () => { } })],68[IStorageService, store.add(new TestStorageService())],69[IProductService, TestProductService],70);7172instantiationService = store.add(new TestInstantiationService(services));7374transport.setConnectionState({ state: McpConnectionState.Kind.Running });7576// Manually create the handler since we need the transport already set up77const logger = store.add((instantiationService.get(ILoggerService) as TestLoggerService)78.createLogger('mcpServerTest', { hidden: true, name: 'MCP Test' }));7980// Start the handler creation81const handlerPromise = McpServerRequestHandler.create(instantiationService, { logger, launch: transport }, cts.token);8283handler = await handlerPromise;84store.add(handler);85});8687test('should send and receive JSON-RPC requests', async () => {88// Setup request89const requestPromise = handler.listResources();9091// Get the sent message and verify it92const sentMessages = transport.getSentMessages();93assert.strictEqual(sentMessages.length, 3); // initialize + listResources9495// Verify listResources request format96const listResourcesRequest = sentMessages[2] as MCP.JSONRPCRequest;97assert.strictEqual(listResourcesRequest.method, 'resources/list');98assert.strictEqual(listResourcesRequest.jsonrpc, MCP.JSONRPC_VERSION);99assert.ok(typeof listResourcesRequest.id === 'number');100101// Simulate server response with mock resources that match the expected Resource interface102transport.simulateReceiveMessage({103jsonrpc: MCP.JSONRPC_VERSION,104id: listResourcesRequest.id,105result: {106resources: [107{ uri: 'resource1', type: 'text/plain', name: 'Test Resource 1' },108{ uri: 'resource2', type: 'text/plain', name: 'Test Resource 2' }109]110}111});112113// Verify the result114const resources = await requestPromise;115assert.strictEqual(resources.length, 2);116assert.strictEqual(resources[0].uri, 'resource1');117assert.strictEqual(resources[1].name, 'Test Resource 2');118});119120test('should handle paginated requests', async () => {121// Setup request122const requestPromise = handler.listResources();123124// Get the first request and respond with pagination125const sentMessages = transport.getSentMessages();126const listResourcesRequest = sentMessages[2] as MCP.JSONRPCRequest;127128// Send first page with nextCursor129transport.simulateReceiveMessage({130jsonrpc: MCP.JSONRPC_VERSION,131id: listResourcesRequest.id,132result: {133resources: [134{ uri: 'resource1', type: 'text/plain', name: 'Test Resource 1' }135],136nextCursor: 'page2'137}138});139140// Clear the sent messages to only capture the next page request141transport.clearSentMessages();142143// Wait a bit to allow the handler to process and send the next request144await new Promise(resolve => setTimeout(resolve, 0));145146// Get the second request and verify cursor is included147const sentMessages2 = transport.getSentMessages();148assert.strictEqual(sentMessages2.length, 1);149150const listResourcesRequest2 = sentMessages2[0] as MCP.JSONRPCRequest;151assert.strictEqual(listResourcesRequest2.method, 'resources/list');152assert.deepStrictEqual(listResourcesRequest2.params, { cursor: 'page2' });153154// Send final page with no nextCursor155transport.simulateReceiveMessage({156jsonrpc: MCP.JSONRPC_VERSION,157id: listResourcesRequest2.id,158result: {159resources: [160{ uri: 'resource2', type: 'text/plain', name: 'Test Resource 2' }161]162}163});164165// Verify the combined result166const resources = await requestPromise;167assert.strictEqual(resources.length, 2);168assert.strictEqual(resources[0].uri, 'resource1');169assert.strictEqual(resources[1].uri, 'resource2');170});171172test('should handle error responses', async () => {173// Setup request174const requestPromise = handler.readResource({ uri: 'non-existent' });175176// Get the sent message177const sentMessages = transport.getSentMessages();178const readResourceRequest = sentMessages[2] as MCP.JSONRPCRequest; // [0] is initialize179180// Simulate error response181transport.simulateReceiveMessage({182jsonrpc: MCP.JSONRPC_VERSION,183id: readResourceRequest.id,184error: {185code: MCP.METHOD_NOT_FOUND,186message: 'Resource not found'187}188});189190// Verify the error is thrown correctly191try {192await requestPromise;193assert.fail('Expected error was not thrown');194} catch (e: any) {195assert.strictEqual(e.message, 'MPC -32601: Resource not found');196assert.strictEqual(e.code, MCP.METHOD_NOT_FOUND);197}198});199200test('should handle server requests', async () => {201// Simulate ping request from server202const pingRequest: MCP.JSONRPCRequest & MCP.PingRequest = {203jsonrpc: MCP.JSONRPC_VERSION,204id: 100,205method: 'ping'206};207208transport.simulateReceiveMessage(pingRequest);209210// The handler should have sent a response211const sentMessages = transport.getSentMessages();212const pingResponse = sentMessages.find(m =>213'id' in m && m.id === pingRequest.id && 'result' in m214) as MCP.JSONRPCResponse;215216assert.ok(pingResponse, 'No ping response was sent');217assert.deepStrictEqual(pingResponse.result, {});218});219220test('should handle roots list requests', async () => {221// Set roots222handler.roots = [223{ uri: 'file:///test/root1', name: 'Root 1' },224{ uri: 'file:///test/root2', name: 'Root 2' }225];226227// Simulate roots/list request from server228const rootsRequest: MCP.JSONRPCRequest & MCP.ListRootsRequest = {229jsonrpc: MCP.JSONRPC_VERSION,230id: 101,231method: 'roots/list'232};233234transport.simulateReceiveMessage(rootsRequest);235236// The handler should have sent a response237const sentMessages = transport.getSentMessages();238const rootsResponse = sentMessages.find(m =>239'id' in m && m.id === rootsRequest.id && 'result' in m240) as MCP.JSONRPCResponse;241242assert.ok(rootsResponse, 'No roots/list response was sent');243assert.strictEqual((rootsResponse.result as MCP.ListRootsResult).roots.length, 2);244assert.strictEqual((rootsResponse.result as MCP.ListRootsResult).roots[0].uri, 'file:///test/root1');245});246247test('should handle server notifications', async () => {248let progressNotificationReceived = false;249store.add(handler.onDidReceiveProgressNotification(notification => {250progressNotificationReceived = true;251assert.strictEqual(notification.method, 'notifications/progress');252assert.strictEqual(notification.params.progressToken, 'token1');253assert.strictEqual(notification.params.progress, 50);254}));255256// Simulate progress notification with correct format257const progressNotification: MCP.JSONRPCNotification & MCP.ProgressNotification = {258jsonrpc: MCP.JSONRPC_VERSION,259method: 'notifications/progress',260params: {261progressToken: 'token1',262progress: 50,263total: 100264}265};266267transport.simulateReceiveMessage(progressNotification);268assert.strictEqual(progressNotificationReceived, true);269});270271test('should handle cancellation', async () => {272// Setup a new cancellation token source for this specific test273const testCts = store.add(new CancellationTokenSource());274const requestPromise = handler.listResources(undefined, testCts.token);275276// Get the request ID277const sentMessages = transport.getSentMessages();278const listResourcesRequest = sentMessages[2] as MCP.JSONRPCRequest;279const requestId = listResourcesRequest.id;280281// Cancel the request282testCts.cancel();283284// Check that a cancellation notification was sent285const cancelNotification = transport.getSentMessages().find(m =>286!('id' in m) &&287'method' in m &&288m.method === 'notifications/cancelled' &&289'params' in m &&290m.params && m.params.requestId === requestId291);292293assert.ok(cancelNotification, 'No cancellation notification was sent');294295// Verify the promise was cancelled296try {297await requestPromise;298assert.fail('Promise should have been cancelled');299} catch (e) {300assert.strictEqual(e.name, 'Canceled');301}302});303304test('should handle cancelled notification from server', async () => {305// Setup request306const requestPromise = handler.listResources();307308// Get the request ID309const sentMessages = transport.getSentMessages();310const listResourcesRequest = sentMessages[2] as MCP.JSONRPCRequest;311const requestId = listResourcesRequest.id;312313// Simulate cancelled notification from server314const cancelledNotification: MCP.JSONRPCNotification & MCP.CancelledNotification = {315jsonrpc: MCP.JSONRPC_VERSION,316method: 'notifications/cancelled',317params: {318requestId319}320};321322transport.simulateReceiveMessage(cancelledNotification);323324// Verify the promise was cancelled325try {326await requestPromise;327assert.fail('Promise should have been cancelled');328} catch (e) {329assert.strictEqual(e.name, 'Canceled');330}331});332333test('should dispose properly and cancel pending requests', async () => {334// Setup multiple requests335const request1 = handler.listResources();336const request2 = handler.listTools();337338// Dispose the handler339handler.dispose();340341// Verify all promises were cancelled342try {343await request1;344assert.fail('Promise 1 should have been cancelled');345} catch (e) {346assert.strictEqual(e.name, 'Canceled');347}348349try {350await request2;351assert.fail('Promise 2 should have been cancelled');352} catch (e) {353assert.strictEqual(e.name, 'Canceled');354}355});356357test('should handle connection error by cancelling requests', async () => {358// Setup request359const requestPromise = handler.listResources();360361// Simulate connection error362transport.setConnectionState({363state: McpConnectionState.Kind.Error,364message: 'Connection lost'365});366367// Verify the promise was cancelled368try {369await requestPromise;370assert.fail('Promise should have been cancelled');371} catch (e) {372assert.strictEqual(e.name, 'Canceled');373}374});375});376377378