Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/vs/workbench/contrib/mcp/common/mcpRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,8 @@ export class McpRegistry extends Disposable implements IMcpRegistry {

const definition = collection?.serverDefinitions.get().find(s => s.id === definitionRef.id);
if (!collection || !definition) {
throw new Error(`Collection or definition not found for ${collectionRef.id} and ${definitionRef.id}`);
logger.debug(`Skipping MCP server ${definitionRef.id}: collection ${collectionRef.id} or server definition is no longer registered.`);
return undefined;
}

const delegate = this._delegates.get().find(d => d.canStart(collection, definition));
Expand Down
2 changes: 1 addition & 1 deletion src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,6 @@ export interface IMcpRegistry {
setSavedInput(inputId: string, target: ConfigurationTarget, value: string): Promise<void>;
/** Gets saved inputs from storage. */
getSavedInputs(scope: StorageScope): Promise<{ [id: string]: IResolvedValue }>;
/** Creates a connection for the collection and definition. */
/** Creates a connection, or returns undefined if startup is cancelled or the collection or definition is no longer registered. */
resolveConnection(options: IMcpResolveConnectionOptions): Promise<IMcpServerConnection | undefined>;
}
94 changes: 93 additions & 1 deletion src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import * as assert from 'assert';
import * as sinon from 'sinon';
import { timeout } from '../../../../../base/common/async.js';
import { DeferredPromise, timeout } from '../../../../../base/common/async.js';
import { Disposable, toDisposable } from '../../../../../base/common/lifecycle.js';
import { ISettableObservable, observableValue } from '../../../../../base/common/observable.js';
import { URI } from '../../../../../base/common/uri.js';
Expand Down Expand Up @@ -257,6 +257,10 @@ suite('Workbench - MCP - Registry', () => {
};
});

teardown(() => {
sinon.restore();
});

test('registerCollection adds collection to registry', () => {
const disposable = registry.registerCollection(testCollection);
store.add(disposable);
Expand Down Expand Up @@ -330,6 +334,94 @@ suite('Workbench - MCP - Registry', () => {
assert.strictEqual(registry.delegates.get().length, 0);
});

for (const removed of ['collection', 'definition'] as const) {
for (const duringLoad of [false, true]) {
test(`resolveConnection skips a removed ${removed} ${duringLoad ? 'during lazy loading' : 'before resolution'}`, async () => {
const loaded = new DeferredPromise<void>();
const resolveLaunch = sinon.stub().resolves(baseDefinition.launch);
const resolveInputs = sinon.spy(testConfigResolverService, 'resolveWithInteraction');
const delegate = new TestMcpHostDelegate();
const substituteVariables = sinon.spy(delegate, 'substituteVariables');
store.add(registry.registerDelegate(delegate));
testCollection.serverDefinitions.set([baseDefinition], undefined);
const collection: McpCollectionDefinition = {
...testCollection,
resolveServerLanch: resolveLaunch,
lazy: duringLoad ? { isCached: true, load: () => loaded.p } : undefined,
};
const registration = store.add(registry.registerCollection(collection));
const resolve = () => registry.resolveConnection({ collectionRef: collection, definitionRef: baseDefinition, logger, trustNonceBearer, taskManager });
const pending = duringLoad ? resolve() : undefined;

if (removed === 'collection') {
registration.dispose();
} else {
testCollection.serverDefinitions.set([], undefined);
}
await loaded.complete();

const connection = await (pending ?? resolve());
if (connection) {
store.add(connection);
}
assert.deepStrictEqual({
connection,
launchResolutions: resolveLaunch.callCount,
variableSubstitutions: substituteVariables.callCount,
inputResolutions: resolveInputs.callCount,
sandboxLaunches: testMcpSandboxService.callCount,
}, {
connection: undefined,
launchResolutions: 0,
variableSubstitutions: 0,
inputResolutions: 0,
sandboxLaunches: 0,
});
});
}
}

test('resolveConnection still rejects a lazy loading error', async () => {
const error = new Error('Failed to load the MCP collection');
const collection: McpCollectionDefinition = {
...testCollection,
lazy: { isCached: true, load: async () => { throw error; } },
};
store.add(registry.registerCollection(collection));

await assert.rejects(
registry.resolveConnection({ collectionRef: collection, definitionRef: baseDefinition, logger, trustNonceBearer, taskManager }),
error,
);
});

test('resolveConnection still rejects a missing delegate', async () => {
testCollection.serverDefinitions.set([baseDefinition], undefined);
store.add(registry.registerCollection(testCollection));

await assert.rejects(
registry.resolveConnection({ collectionRef: testCollection, definitionRef: baseDefinition, logger, trustNonceBearer, taskManager }),
/No delegate found that can handle the connection/,
);
});

test('resolveConnection still rejects an enterprise customization restriction', async () => {
testCollection.serverDefinitions.set([baseDefinition], undefined);
store.add(registry.registerCollection(testCollection));
await configurationService.setUserConfiguration(COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_CONFIG, true);
configurationService.onDidChangeConfigurationEmitter.fire({
source: ConfigurationTarget.USER,
affectedKeys: new Set([COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_CONFIG]),
change: { keys: [COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_CONFIG], overrides: [] },
affectsConfiguration: key => key === COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_CONFIG,
});

await assert.rejects(
registry.resolveConnection({ collectionRef: testCollection, definitionRef: baseDefinition, logger, trustNonceBearer, taskManager }),
/MCP collection test-collection is blocked by enterprise customization policy/,
);
});

test('resolveConnection creates connection with resolved variables and memorizes them until cleared', async () => {
const definition: McpServerDefinition = {
...baseDefinition,
Expand Down
Loading