diff --git a/build/lib/layersChecker.js b/build/lib/layersChecker.js index 5cf5c58402c..8e46cb71541 100644 --- a/build/lib/layersChecker.js +++ b/build/lib/layersChecker.js @@ -81,7 +81,8 @@ const CORE_TYPES = [ 'ImportMeta', // webcrypto has been available since Node.js 19, but still live in dom.d.ts 'Crypto', - 'SubtleCrypto' + 'SubtleCrypto', + 'JsonWebKey', ]; // Types that are defined in a common layer but are known to be only // available in native environments should not be allowed in browser diff --git a/build/lib/layersChecker.ts b/build/lib/layersChecker.ts index 63377328928..235d2d68dbe 100644 --- a/build/lib/layersChecker.ts +++ b/build/lib/layersChecker.ts @@ -80,7 +80,8 @@ const CORE_TYPES = [ // webcrypto has been available since Node.js 19, but still live in dom.d.ts 'Crypto', - 'SubtleCrypto' + 'SubtleCrypto', + 'JsonWebKey', ]; // Types that are defined in a common layer but are known to be only diff --git a/src/vs/platform/secrets/test/common/testSecretStorageService.ts b/src/vs/platform/secrets/test/common/testSecretStorageService.ts new file mode 100644 index 00000000000..ec7482e72bb --- /dev/null +++ b/src/vs/platform/secrets/test/common/testSecretStorageService.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../../base/common/event.js'; +import { ISecretStorageService } from '../../common/secrets.js'; + +export class TestSecretStorageService implements ISecretStorageService { + declare readonly _serviceBrand: undefined; + + private readonly _storage = new Map(); + private readonly _onDidChangeSecretEmitter = new Emitter(); + readonly onDidChangeSecret = this._onDidChangeSecretEmitter.event; + + type = 'in-memory' as const; + + async get(key: string): Promise { + return this._storage.get(key); + } + + async set(key: string, value: string): Promise { + this._storage.set(key, value); + this._onDidChangeSecretEmitter.fire(key); + } + + async delete(key: string): Promise { + this._storage.delete(key); + this._onDidChangeSecretEmitter.fire(key); + } + + // Helper method for tests to clear all secrets + clear(): void { + this._storage.clear(); + } +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts index fb96aa95cdd..b6a317949e0 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts @@ -3,15 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Lazy } from '../../../../base/common/lazy.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; -import { isEmptyObject } from '../../../../base/common/types.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js'; +import { McpRegistryInputStorage } from './mcpRegistryInputStorage.js'; import { IMcpHostDelegate, IMcpRegistry } from './mcpRegistryTypes.js'; import { McpServerConnection } from './mcpServerConnection.js'; -import { McpCollectionDefinition, IMcpServerConnection, McpServerDefinition } from './mcpTypes.js'; +import { IMcpServerConnection, McpCollectionDefinition, McpServerDefinition } from './mcpTypes.js'; export class McpRegistry extends Disposable implements IMcpRegistry { declare public readonly _serviceBrand: undefined; @@ -21,6 +22,9 @@ export class McpRegistry extends Disposable implements IMcpRegistry { public readonly collections: IObservable = this._collections; + private readonly _workspaceStorage = new Lazy(() => this._register(this._instantiationService.createInstance(McpRegistryInputStorage, StorageScope.WORKSPACE, StorageTarget.USER))); + private readonly _profileStorage = new Lazy(() => this._register(this._instantiationService.createInstance(McpRegistryInputStorage, StorageScope.PROFILE, StorageTarget.USER))); + public get delegates(): readonly IMcpHostDelegate[] { return this._delegates; } @@ -28,7 +32,6 @@ export class McpRegistry extends Disposable implements IMcpRegistry { constructor( @IInstantiationService private readonly _instantiationService: IInstantiationService, @IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService, - @IStorageService private readonly _storageService: IStorageService, ) { super(); } @@ -57,16 +60,9 @@ export class McpRegistry extends Disposable implements IMcpRegistry { }; } - public hasSavedInputs(collection: McpCollectionDefinition, definition: McpServerDefinition): boolean { - const stored = this.getInputStorageData(collection, definition); - return !!stored && !!stored.map && !isEmptyObject(stored.map); - } - - public clearSavedInputs(collection: McpCollectionDefinition, definition: McpServerDefinition) { - const stored = this.getInputStorageData(collection, definition); - if (stored) { - this._storageService.remove(stored.key, stored.scope); - } + public clearSavedInputs() { + this._profileStorage.value.clearAll(); + this._workspaceStorage.value.clearAll(); } public async resolveConnection( @@ -80,17 +76,21 @@ export class McpRegistry extends Disposable implements IMcpRegistry { let launch = definition.launch; - const storage = this.getInputStorageData(collection, definition); - if (definition.variableReplacement && storage) { + if (definition.variableReplacement) { + const inputStorage = definition.variableReplacement.folder ? this._workspaceStorage.value : this._profileStorage.value; + const previouslyStored = await inputStorage.getMap(); + const { folder, section, target } = definition.variableReplacement; + // based on _configurationResolverService.resolveWithInteractionReplace launch = await this._configurationResolverService.resolveAnyAsync(folder, launch); - const newVariables = await this._configurationResolverService.resolveWithInteraction(folder, launch, section, storage.map, target); + const newVariables = await this._configurationResolverService.resolveWithInteraction(folder, launch, section, previouslyStored, target); if (newVariables?.size) { - launch = await this._configurationResolverService.resolveAnyAsync(folder, launch, Object.fromEntries(newVariables)); - this._storageService.store(storage.key, JSON.stringify(Object.fromEntries(newVariables)), storage.scope, StorageTarget.MACHINE); + const completeVariables = { ...previouslyStored, ...Object.fromEntries(newVariables) }; + launch = await this._configurationResolverService.resolveAnyAsync(folder, launch, completeVariables); + await inputStorage.setSecrets(completeVariables); } } @@ -102,23 +102,5 @@ export class McpRegistry extends Disposable implements IMcpRegistry { launch, ); } - - private getInputStorageData(collection: McpCollectionDefinition, definition: McpServerDefinition) { - if (!definition.variableReplacement) { - return undefined; - } - - const key = `mcpConfig.${collection.id}.${definition.id}`; - const scope = definition.variableReplacement.folder ? StorageScope.WORKSPACE : StorageScope.APPLICATION; - - let map: Record | undefined; - try { - map = this._storageService.getObject(key, scope); - } catch { - // ignord - } - - return { key, scope, map }; - } } diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistryInputStorage.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistryInputStorage.ts new file mode 100644 index 00000000000..6e23a059998 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistryInputStorage.ts @@ -0,0 +1,192 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Sequencer } from '../../../../base/common/async.js'; +import { decodeBase64, encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; +import { Lazy } from '../../../../base/common/lazy.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { isEmptyObject } from '../../../../base/common/types.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ISecretStorageService } from '../../../../platform/secrets/common/secrets.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +const MCP_ENCRYPTION_KEY_NAME = 'mcpEncryptionKey'; +const MCP_ENCRYPTION_KEY_ALGORITHM = 'AES-GCM'; +const MCP_ENCRYPTION_KEY_LEN = 256; +const MCP_ENCRYPTION_IV_LENGTH = 12; // 96 bits +const MCP_DATA_STORED_VERSION = 1; +const MCP_DATA_STORED_KEY = 'mcpInputs'; + +interface IStoredData { + version: number; + values: Record; + secrets?: { value: string; iv: string }; // base64, encrypted +} + +interface IHydratedData extends IStoredData { + unsealedSecrets?: Record; +} + +export class McpRegistryInputStorage extends Disposable { + private static secretSequencer = new Sequencer(); + private readonly _secretsSealerSequencer = new Sequencer(); + + private readonly _getEncryptionKey = new Lazy(() => { + return McpRegistryInputStorage.secretSequencer.queue(async () => { + const existing = await this._secretStorageService.get(MCP_ENCRYPTION_KEY_NAME); + if (existing) { + try { + const parsed: JsonWebKey = JSON.parse(existing); + return await crypto.subtle.importKey('jwk', parsed, MCP_ENCRYPTION_KEY_ALGORITHM, false, ['encrypt', 'decrypt']); + } catch { + // fall through + } + } + + const key = await crypto.subtle.generateKey( + { name: MCP_ENCRYPTION_KEY_ALGORITHM, length: MCP_ENCRYPTION_KEY_LEN }, + true, + ['encrypt', 'decrypt'], + ); + + const exported = await crypto.subtle.exportKey('jwk', key); + await this._secretStorageService.set(MCP_ENCRYPTION_KEY_NAME, JSON.stringify(exported)); + return key; + }); + }); + + private _didChange = false; + + private _record = new Lazy(() => { + const stored = this._storageService.getObject(MCP_DATA_STORED_KEY, this._scope); + return stored?.version === MCP_DATA_STORED_VERSION ? { ...stored } : { version: MCP_DATA_STORED_VERSION, values: {} }; + }); + + + constructor( + private readonly _scope: StorageScope, + _target: StorageTarget, + @IStorageService private readonly _storageService: IStorageService, + @ISecretStorageService private readonly _secretStorageService: ISecretStorageService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + this._register(_storageService.onWillSaveState(() => { + if (this._didChange) { + this._storageService.store(MCP_DATA_STORED_KEY, { + version: MCP_DATA_STORED_VERSION, + values: this._record.value.values, + secrets: this._record.value.secrets, + } satisfies IStoredData, this._scope, _target); + this._didChange = false; + } + })); + } + + /** Deletes all collection data from storage. */ + public clearAll() { + this._record.value.values = {}; + this._record.value.secrets = undefined; + this._record.value.unsealedSecrets = undefined; + this._didChange = true; + } + + /** Delete a single collection data from the storage. */ + public async clear(inputKey: string) { + const secrets = await this._unsealSecrets(); + delete this._record.value.values[inputKey]; + this._didChange = true; + + if (secrets.hasOwnProperty(inputKey)) { + delete secrets[inputKey]; + await this._sealSecrets(); + } + } + + /** Gets a mapping of saved input data. */ + public async getMap() { + const secrets = await this._unsealSecrets(); + return { ...this._record.value.values, ...secrets }; + } + + /** Updates the input data mapping. */ + public async setPlainText(values: Record) { + Object.assign(this._record.value.values, values); + this._didChange = true; + } + + /** Updates the input secrets mapping. */ + public async setSecrets(values: Record) { + const unsealed = await this._unsealSecrets(); + Object.assign(unsealed, values); + await this._sealSecrets(); + } + + private async _sealSecrets() { + return this._secretsSealerSequencer.queue(async () => { + if (!this._record.value.unsealedSecrets || isEmptyObject(this._record.value.unsealedSecrets)) { + this._record.value.secrets = undefined; + return; + } + + if (!this._record.value.secrets) { + const iv = crypto.getRandomValues(new Uint8Array(MCP_ENCRYPTION_IV_LENGTH)); + this._record.value.secrets = { + value: '', + iv: encodeBase64(VSBuffer.wrap(iv)), + }; + } + + const toSeal = JSON.stringify(this._record.value.unsealedSecrets); + const iv = decodeBase64(this._record.value.secrets.iv); + const key = await this._getEncryptionKey.value; + const encrypted = await crypto.subtle.encrypt( + { name: MCP_ENCRYPTION_KEY_ALGORITHM, iv: iv.buffer }, + key, + new TextEncoder().encode(toSeal).buffer, + ); + + const enc = encodeBase64(VSBuffer.wrap(new Uint8Array(encrypted))); + if (this._record.value.secrets.value === enc) { + return; + } + + this._record.value.secrets.value = enc; + this._didChange = true; + }); + } + + private async _unsealSecrets(): Promise> { + if (!this._record.value.secrets) { + return this._record.value.unsealedSecrets ??= {}; + } + + if (this._record.value.unsealedSecrets) { + return this._record.value.unsealedSecrets; + } + + try { + const key = await this._getEncryptionKey.value; + const iv = decodeBase64(this._record.value.secrets.iv); + const encrypted = decodeBase64(this._record.value.secrets.value); + + const decrypted = await crypto.subtle.decrypt( + { name: MCP_ENCRYPTION_KEY_ALGORITHM, iv: iv.buffer }, + key, + encrypted.buffer, + ); + + const unsealedSecrets = JSON.parse(new TextDecoder().decode(decrypted)); + this._record.value.unsealedSecrets = unsealedSecrets; + return unsealedSecrets; + } catch (e) { + this._logService.warn('Error unsealing MCP secrets', e); + this._record.value.secrets = undefined; + } + + return {}; + } +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts index 9659bc986da..12f5123a803 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts @@ -35,8 +35,6 @@ export interface IMcpRegistry { registerDelegate(delegate: IMcpHostDelegate): IDisposable; registerCollection(collection: McpCollectionDefinition): IDisposable; - /** Gets whether there are saved inputs used to resolve the connection */ - hasSavedInputs(collection: McpCollectionDefinition, definition: McpServerDefinition): boolean; /** Resets any saved inputs for the connection. */ clearSavedInputs(collection: McpCollectionDefinition, definition: McpServerDefinition): void; /** Createse a connection for the collection and definition. */ diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts index 19465290098..6f9f02f2a58 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts @@ -13,7 +13,9 @@ import { ConfigurationTarget } from '../../../../../platform/configuration/commo import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILoggerService } from '../../../../../platform/log/common/log.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ISecretStorageService } from '../../../../../platform/secrets/common/secrets.js'; +import { TestSecretStorageService } from '../../../../../platform/secrets/test/common/testSecretStorageService.js'; +import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { IConfigurationResolverService } from '../../../../services/configurationResolver/common/configurationResolver.js'; import { IOutputService } from '../../../../services/output/common/output.js'; import { TestLoggerService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; @@ -131,6 +133,7 @@ suite('Workbench - MCP - Registry', () => { const services = new ServiceCollection( [IConfigurationResolverService, testConfigResolverService], [IStorageService, testStorageService], + [ISecretStorageService, new TestSecretStorageService()], [ILoggerService, store.add(new TestLoggerService())], [IOutputService, upcast({ showChannel: () => { } })], ); @@ -185,28 +188,7 @@ suite('Workbench - MCP - Registry', () => { assert.strictEqual(registry.delegates.length, 0); }); - test('hasSavedInputs returns false when no inputs are saved', () => { - assert.strictEqual(registry.hasSavedInputs(testCollection, baseDefinition), false); - }); - - test('clearSavedInputs removes stored inputs', () => { - const definition: McpServerDefinition = { - ...baseDefinition, - variableReplacement: { - section: 'mcp' - } - }; - - // Save some mock inputs - const key = `mcpConfig.${testCollection.id}.${definition.id}`; - testStorageService.store(key, JSON.stringify({ 'input:foo': 'bar' }), StorageScope.APPLICATION, StorageTarget.MACHINE); - - assert.strictEqual(registry.hasSavedInputs(testCollection, definition), true); - registry.clearSavedInputs(testCollection, definition); - assert.strictEqual(registry.hasSavedInputs(testCollection, definition), false); - }); - - test('resolveConnection creates connection with resolved variables and memorizes them', async () => { + test('resolveConnection creates connection with resolved variables and memorizes them until cleared', async () => { const definition: McpServerDefinition = { ...baseDefinition, launch: { @@ -241,36 +223,13 @@ suite('Workbench - MCP - Registry', () => { assert.ok(connection2); assert.strictEqual((connection2.launchDefinition as any).env.PATH, 'interactiveValue0'); connection2.dispose(); - }); - test('resolveConnection with stored variables resolves them', async () => { - const definition: McpServerDefinition = { - ...baseDefinition, - launch: { - type: McpServerTransportType.Stdio, - command: '${storedVar}', - args: [], - env: {}, - cwd: URI.parse('file:///test') - }, - variableReplacement: { - section: 'mcp' - } - }; + registry.clearSavedInputs(); - // Save some mock inputs - const key = `mcpConfig.${testCollection.id}.${definition.id}`; - testStorageService.store(key, { 'storedVar': 'resolved-value' }, StorageScope.APPLICATION, StorageTarget.MACHINE); + const connection3 = await registry.resolveConnection(testCollection, definition) as McpServerConnection; - // Register a delegate that can handle the connection - const delegate = new TestMcpHostDelegate(); - const disposable = registry.registerDelegate(delegate); - store.add(disposable); - - const connection = await registry.resolveConnection(testCollection, definition) as McpServerConnection; - - assert.ok(connection); - assert.strictEqual((connection.launchDefinition as any).command, 'resolved-value'); - connection.dispose(); + assert.ok(connection3); + assert.strictEqual((connection3.launchDefinition as any).env.PATH, 'interactiveValue4'); + connection3.dispose(); }); }); diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistryInputStorage.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistryInputStorage.test.ts new file mode 100644 index 00000000000..f6f745d5751 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistryInputStorage.test.ts @@ -0,0 +1,178 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { TestSecretStorageService } from '../../../../../platform/secrets/test/common/testSecretStorageService.js'; +import { StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { McpRegistryInputStorage } from '../../common/mcpRegistryInputStorage.js'; + +suite('Workbench - MCP - RegistryInputStorage', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let testStorageService: TestStorageService; + let testSecretStorageService: TestSecretStorageService; + let testLogService: ILogService; + let mcpInputStorage: McpRegistryInputStorage; + + setup(() => { + testStorageService = store.add(new TestStorageService()); + testSecretStorageService = new TestSecretStorageService(); + testLogService = store.add(new NullLogService()); + + // Create the input storage with APPLICATION scope + mcpInputStorage = store.add(new McpRegistryInputStorage( + StorageScope.APPLICATION, + StorageTarget.MACHINE, + testStorageService, + testSecretStorageService, + testLogService + )); + }); + + test('setPlainText stores values that can be retrieved with getMap', async () => { + const values = { + 'key1': 'value1', + 'key2': 'value2' + }; + + await mcpInputStorage.setPlainText(values); + const result = await mcpInputStorage.getMap(); + + assert.strictEqual(result.key1, 'value1'); + assert.strictEqual(result.key2, 'value2'); + }); + + test('setSecrets stores encrypted values that can be retrieved with getMap', async () => { + const secrets = { + 'secretKey1': 'secretValue1', + 'secretKey2': 'secretValue2' + }; + + await mcpInputStorage.setSecrets(secrets); + const result = await mcpInputStorage.getMap(); + + assert.strictEqual(result.secretKey1, 'secretValue1'); + assert.strictEqual(result.secretKey2, 'secretValue2'); + }); + + test('getMap returns combined plain text and secret values', async () => { + await mcpInputStorage.setPlainText({ + 'plainKey': 'plainValue' + }); + + await mcpInputStorage.setSecrets({ + 'secretKey': 'secretValue' + }); + + const result = await mcpInputStorage.getMap(); + + assert.strictEqual(result.plainKey, 'plainValue'); + assert.strictEqual(result.secretKey, 'secretValue'); + }); + + test('clear removes specific values', async () => { + await mcpInputStorage.setPlainText({ + 'key1': 'value1', + 'key2': 'value2' + }); + + await mcpInputStorage.setSecrets({ + 'secretKey1': 'secretValue1', + 'secretKey2': 'secretValue2' + }); + + // Clear one plain and one secret value + await mcpInputStorage.clear('key1'); + await mcpInputStorage.clear('secretKey1'); + + const result = await mcpInputStorage.getMap(); + + assert.strictEqual(result.key1, undefined); + assert.strictEqual(result.key2, 'value2'); + assert.strictEqual(result.secretKey1, undefined); + assert.strictEqual(result.secretKey2, 'secretValue2'); + }); + + test('clearAll removes all values', async () => { + await mcpInputStorage.setPlainText({ + 'key1': 'value1' + }); + + await mcpInputStorage.setSecrets({ + 'secretKey1': 'secretValue1' + }); + + mcpInputStorage.clearAll(); + + const result = await mcpInputStorage.getMap(); + + assert.deepStrictEqual(result, {}); + }); + + test('updates to plain text values overwrite existing values', async () => { + await mcpInputStorage.setPlainText({ + 'key1': 'value1', + 'key2': 'value2' + }); + + await mcpInputStorage.setPlainText({ + 'key1': 'updatedValue1' + }); + + const result = await mcpInputStorage.getMap(); + + assert.strictEqual(result.key1, 'updatedValue1'); + assert.strictEqual(result.key2, 'value2'); + }); + + test('updates to secret values overwrite existing values', async () => { + await mcpInputStorage.setSecrets({ + 'secretKey1': 'secretValue1', + 'secretKey2': 'secretValue2' + }); + + await mcpInputStorage.setSecrets({ + 'secretKey1': 'updatedSecretValue1' + }); + + const result = await mcpInputStorage.getMap(); + + assert.strictEqual(result.secretKey1, 'updatedSecretValue1'); + assert.strictEqual(result.secretKey2, 'secretValue2'); + }); + + test('storage persists values across instances', async () => { + // Set values on first instance + await mcpInputStorage.setPlainText({ + 'key1': 'value1' + }); + + await mcpInputStorage.setSecrets({ + 'secretKey1': 'secretValue1' + }); + + await testStorageService.flush(); + + // Create a second instance that should have access to the same storage + const secondInstance = store.add(new McpRegistryInputStorage( + StorageScope.APPLICATION, + StorageTarget.MACHINE, + testStorageService, + testSecretStorageService, + testLogService + )); + + const result = await secondInstance.getMap(); + + assert.strictEqual(result.key1, 'value1'); + assert.strictEqual(result.secretKey1, 'secretValue1'); + + assert.ok(!testStorageService.get('mcpInputs', StorageScope.APPLICATION)?.includes('secretValue1')); + }); +}); +