diff --git a/packages/backend/src/studio-api-impl.spec.ts b/packages/backend/src/studio-api-impl.spec.ts index e44e46de5..277b9722b 100644 --- a/packages/backend/src/studio-api-impl.spec.ts +++ b/packages/backend/src/studio-api-impl.spec.ts @@ -43,6 +43,7 @@ import type { RecipeManager } from './managers/recipes/RecipeManager'; import type { PodmanConnection } from './managers/podmanConnection'; import type { NavigationRegistry } from './registries/NavigationRegistry'; import type { RpcExtension } from '@shared/messages/MessageProxy'; +import type { McpServerManager } from './managers/playground/McpServerManager'; vi.mock('./ai.json', () => { return { @@ -103,6 +104,7 @@ let studioApiImpl: StudioApiImpl; let catalogManager: CatalogManager; let localRepositoryRegistry: LocalRepositoryRegistry; let applicationManager: ApplicationManager; +let mcpServerManager: McpServerManager; const podmanConnectionMock: PodmanConnection = { findRunningContainerProviderConnection: vi.fn(), @@ -139,6 +141,10 @@ beforeEach(async () => { logError: vi.fn(), } as unknown as TelemetryLogger; + mcpServerManager = { + getMcpSettings: vi.fn(), + } as unknown as McpServerManager; + // Creating StudioApiImpl studioApiImpl = new StudioApiImpl( applicationManager, @@ -148,6 +154,7 @@ beforeEach(async () => { localRepositoryRegistry, {} as unknown as TaskRegistry, {} as unknown as InferenceManager, + mcpServerManager, {} as unknown as PlaygroundV2Manager, {} as unknown as SnippetManager, {} as unknown as CancellationTokenRegistry, @@ -374,3 +381,8 @@ test('navigateToEditConnectionProvider should call navigation.navigateToEditProv await timeout(0); expect(navigationSpy).toHaveBeenCalledWith(connection); }); + +test('getMcpSettings should call mcpServerManager.getMcpSettings', async () => { + await studioApiImpl.getMcpSettings(); + expect(mcpServerManager.getMcpSettings).toHaveBeenCalledOnce(); +}); diff --git a/packages/backend/src/studio-api-impl.ts b/packages/backend/src/studio-api-impl.ts index b75690e1a..539b1c12c 100644 --- a/packages/backend/src/studio-api-impl.ts +++ b/packages/backend/src/studio-api-impl.ts @@ -19,6 +19,7 @@ import type { StudioAPI } from '@shared/StudioAPI'; import type { ApplicationManager } from './managers/application/applicationManager'; import type { ModelInfo } from '@shared/models/IModelInfo'; +import type { McpSettings } from '@shared/models/McpSettings'; import * as podmanDesktopApi from '@podman-desktop/api'; import type { CatalogManager } from './managers/catalogManager'; @@ -33,6 +34,7 @@ import path from 'node:path'; import type { InferenceServer } from '@shared/models/IInference'; import type { CreationInferenceServerOptions } from '@shared/models/InferenceServerConfig'; import type { InferenceManager } from './managers/inference/inferenceManager'; +import type { McpServerManager } from './managers/playground/McpServerManager'; import type { Conversation } from '@shared/models/IPlaygroundMessage'; import type { PlaygroundV2Manager } from './managers/playgroundV2Manager'; import { getFreeRandomPort } from './utils/ports'; @@ -72,6 +74,7 @@ export class StudioApiImpl implements StudioAPI { private localRepositories: LocalRepositoryRegistry, private taskRegistry: TaskRegistry, private inferenceManager: InferenceManager, + private mcpServerManager: McpServerManager, private playgroundV2: PlaygroundV2Manager, private snippetManager: SnippetManager, private cancellationTokenRegistry: CancellationTokenRegistry, @@ -120,6 +123,10 @@ export class StudioApiImpl implements StudioAPI { return this.playgroundV2.getConversations(); } + async getMcpSettings(): Promise { + return this.mcpServerManager.getMcpSettings(); + } + async getExtensionConfiguration(): Promise { return this.configurationRegistry.getExtensionConfiguration(); } diff --git a/packages/backend/src/studio.spec.ts b/packages/backend/src/studio.spec.ts index 816df81b4..58562ed33 100644 --- a/packages/backend/src/studio.spec.ts +++ b/packages/backend/src/studio.spec.ts @@ -92,6 +92,7 @@ vi.mock('@podman-desktop/api', async () => { containerEngine: { onEvent: vi.fn(), listContainers: mocks.listContainers, + listPods: vi.fn(() => []), }, navigation: { register: vi.fn(), @@ -138,17 +139,50 @@ afterEach(() => { console.log = originalConsoleLog; }); -test('check activate', async () => { - expect(version).toBe('1.8.0'); - mocks.listContainers.mockReturnValue([]); - mocks.getContainerConnections.mockReturnValue([]); - (vi.spyOn(fs.promises, 'readFile') as unknown as MockInstance<() => Promise>).mockImplementation(() => { - return Promise.resolve(''); +describe('activate', () => { + beforeEach(async () => { + expect(version).toBe('1.8.0'); + mocks.listContainers.mockReturnValue([]); + mocks.getContainerConnections.mockReturnValue([]); + (vi.spyOn(fs.promises, 'readFile') as unknown as MockInstance<() => Promise>).mockImplementation(() => { + return Promise.resolve(''); + }); + await studio.activate(); + }); + + test('logs the activation message', () => { + // expect the activate method to be called on the studio class + expect(mocks.consoleLogMock).toBeCalledWith('starting AI Lab extension'); }); - await studio.activate(); - // expect the activate method to be called on the studio class - expect(mocks.consoleLogMock).toBeCalledWith('starting AI Lab extension'); + test.each([ + 'RpcExtension', + 'NavigationRegistry', + 'CancellationTokenRegistry', + 'ConfigurationRegistry', + 'ContainerRegistry', + 'PodmanConnection', + 'TaskRegistry', + 'BuilderManager', + 'PodManager', + 'URLModelHandler', + 'HuggingFaceModelHandler', + 'ModelsManager', + 'LocalRepositoryRegistry', + 'GPUManager', + 'InferenceManager', + 'InstructlabManager', + 'LlamaStackManager', + 'RecipeManager', + 'ApplicationManager', + 'McpServerManager', + 'PlaygroundV2Manager', + ])('registers $0 as subscription', (manger: string) => { + const subscriptions = mockedExtensionContext.subscriptions + .filter(s => s?.constructor?.name) + .map(s => s.constructor.name); + expect(subscriptions).toContain(manger); + }); }); describe('version checker', () => { diff --git a/packages/backend/src/studio.ts b/packages/backend/src/studio.ts index f124161ad..cb1613f12 100644 --- a/packages/backend/src/studio.ts +++ b/packages/backend/src/studio.ts @@ -398,6 +398,7 @@ export class Studio { this.#localRepositoryRegistry, this.#taskRegistry, this.#inferenceManager, + this.#mcpServerManager, this.#playgroundManager, this.#snippetManager, this.#cancellationTokenRegistry, diff --git a/packages/frontend/src/lib/conversation/ToolSelectionModal.spec.ts b/packages/frontend/src/lib/conversation/ToolSelectionModal.spec.ts new file mode 100644 index 000000000..f2c9352c1 --- /dev/null +++ b/packages/frontend/src/lib/conversation/ToolSelectionModal.spec.ts @@ -0,0 +1,67 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { expect, test, beforeEach, describe, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, within } from '@testing-library/svelte'; +import type { McpServer } from '@shared/models/McpSettings'; +import ToolSelectionModal from '/@/lib/conversation/ToolSelectionModal.svelte'; + +let container: HTMLElement; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('with servers', () => { + beforeEach(() => { + container = render(ToolSelectionModal, { + mcpServers: [{ name: 'server-1' } as unknown as McpServer, { name: 'server-2' } as unknown as McpServer], + }).container; + }); + describe('closed', () => { + test('has button', () => { + const button = within(container).getByTitle('MCP Servers'); + expect(button).toBeVisible(); + expect(button).toHaveTextContent('MCP Servers'); + }); + test('does not have modal', () => { + const modal = within(container).queryByTestId('tool-selection-modal-tool-container'); + expect(modal).toBeNull(); + }); + }); + describe('open', () => { + let button: HTMLElement; + let modal: HTMLElement; + beforeEach(async () => { + button = within(container).getByTitle('MCP Servers'); + await fireEvent.click(button); + modal = within(container).getByTestId('tool-selection-modal-tool-container'); + }); + test('has modal', () => { + expect(modal).toBeVisible(); + }); + test('contains defined server entries', () => { + const toolEntries = within(modal).getAllByTestId('tool-selection-modal-tool-item'); + expect(toolEntries).toHaveLength(2); + }); + test.each(['server-1', 'server-2'])('has %s entry', name => { + const serverEntry = within(modal).getByText(name); + expect(serverEntry).toBeVisible(); + }); + }); +}); diff --git a/packages/frontend/src/lib/conversation/ToolSelectionModal.svelte b/packages/frontend/src/lib/conversation/ToolSelectionModal.svelte new file mode 100644 index 000000000..f79e3774c --- /dev/null +++ b/packages/frontend/src/lib/conversation/ToolSelectionModal.svelte @@ -0,0 +1,45 @@ + + +
+ + {#if open} + +
+ {#each mcpServers as server (server.name)} +
+ + {server.name} +
+ {/each} +
+
+ {/if} +
diff --git a/packages/frontend/src/pages/Playground.spec.ts b/packages/frontend/src/pages/Playground.spec.ts index 0dc781b10..9408e9445 100644 --- a/packages/frontend/src/pages/Playground.spec.ts +++ b/packages/frontend/src/pages/Playground.spec.ts @@ -36,6 +36,7 @@ vi.mock('../utils/client', async () => { getCatalog: vi.fn(), submitPlaygroundMessage: vi.fn(), requestCancelToken: vi.fn(), + getMcpSettings: vi.fn(async () => ({ servers: {} })), }, rpcBrowser: { subscribe: (): unknown => { diff --git a/packages/frontend/src/pages/Playground.svelte b/packages/frontend/src/pages/Playground.svelte index 7328f14b3..c4dc11b37 100644 --- a/packages/frontend/src/pages/Playground.svelte +++ b/packages/frontend/src/pages/Playground.svelte @@ -11,20 +11,22 @@ import { isAssistantToolCall, type Message, } from '@shared/models/IPlaygroundMessage'; -import { catalog } from '../stores/catalog'; +import { catalog } from '/@/stores/catalog'; +import { mcpSettings } from '/@/stores/mcpSettings'; import ContentDetailsLayout from '../lib/ContentDetailsLayout.svelte'; import RangeInput from '../lib/RangeInput.svelte'; import Fa from 'svelte-fa'; -import ChatMessage from '../lib/conversation/ChatMessage.svelte'; +import ChatMessage from '/@/lib/conversation/ChatMessage.svelte'; +import ConversationActions from '/@/lib/conversation/ConversationActions.svelte'; import SystemPromptBanner from '/@/lib/conversation/SystemPromptBanner.svelte'; +import ToolCallMessage from '/@/lib/conversation/ToolCallMessage.svelte'; +import ToolSelectionModal from '/@/lib/conversation/ToolSelectionModal.svelte'; import { inferenceServers } from '/@/stores/inferenceServers'; import { faCircleInfo, faPaperPlane, faStop } from '@fortawesome/free-solid-svg-icons'; import { Button, Tooltip, DetailsPage, StatusIcon } from '@podman-desktop/ui-svelte'; import { router } from 'tinro'; -import ConversationActions from '../lib/conversation/ConversationActions.svelte'; import { ContainerIcon } from '@podman-desktop/ui-svelte/icons'; -import ToolCallMessage from '/@/lib/conversation/ToolCallMessage.svelte'; import type { InferenceServer } from '@shared/models/IInference'; import type { ModelOptions } from '@shared/models/IModelOptions'; @@ -40,6 +42,7 @@ let errorMsg = $state(''); let cancellationTokenId: number | undefined = $state(undefined); // settings +let mcpServers = $derived(Object.values($mcpSettings.servers).filter(s => s.enabled === true)); let temperature = $state(0.8); let max_tokens = $state(-1); let top_p = $state(0.5); @@ -343,6 +346,11 @@ function handleOnClick(): void { + {#if mcpServers.length > 0} +
+ +
+ {/if} diff --git a/packages/frontend/src/stores/mcpSettings.ts b/packages/frontend/src/stores/mcpSettings.ts new file mode 100644 index 000000000..6ddb0cd3d --- /dev/null +++ b/packages/frontend/src/stores/mcpSettings.ts @@ -0,0 +1,30 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { type Readable } from 'svelte/store'; +import type { McpSettings } from '@shared/models/McpSettings'; +import { MSG_MCP_SERVERS_UPDATE } from '@shared/Messages'; +import { RPCReadable } from '/@/stores/rpcReadable'; +import { studioClient } from '/@/utils/client'; + +export const mcpSettings: Readable = RPCReadable( + { + servers: {}, + } as McpSettings, + MSG_MCP_SERVERS_UPDATE, + studioClient.getMcpSettings, +); diff --git a/packages/shared/src/StudioAPI.ts b/packages/shared/src/StudioAPI.ts index f957fea23..bdca4e13b 100644 --- a/packages/shared/src/StudioAPI.ts +++ b/packages/shared/src/StudioAPI.ts @@ -38,6 +38,7 @@ import type { ExtensionConfiguration } from './models/IExtensionConfiguration'; import type { RecipePullOptions } from './models/IRecipe'; import type { FilterRecipesResult, RecipeFilters } from './models/FilterRecipesResult'; import { createRpcChannel } from './messages/MessageProxy'; +import type { McpSettings } from '@shared/models/McpSettings'; export const STUDIO_API_CHANNEL = createRpcChannel('StudioAPI'); export interface StudioAPI { @@ -175,6 +176,11 @@ export interface StudioAPI { */ getPlaygroundConversations(): Promise; + /** + * Returns the current MCP settings. + */ + getMcpSettings(): Promise; + /** * Get the extension configuration (preferences) */