Skip to content
Open
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
12 changes: 12 additions & 0 deletions packages/backend/src/studio-api-impl.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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();
});
7 changes: 7 additions & 0 deletions packages/backend/src/studio-api-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -120,6 +123,10 @@ export class StudioApiImpl implements StudioAPI {
return this.playgroundV2.getConversations();
}

async getMcpSettings(): Promise<McpSettings> {
return this.mcpServerManager.getMcpSettings();
}

async getExtensionConfiguration(): Promise<ExtensionConfiguration> {
return this.configurationRegistry.getExtensionConfiguration();
}
Expand Down
52 changes: 43 additions & 9 deletions packages/backend/src/studio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ vi.mock('@podman-desktop/api', async () => {
containerEngine: {
onEvent: vi.fn(),
listContainers: mocks.listContainers,
listPods: vi.fn(() => []),
},
navigation: {
register: vi.fn(),
Expand Down Expand Up @@ -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<string>>).mockImplementation(() => {
return Promise.resolve('<html></html>');
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<string>>).mockImplementation(() => {
return Promise.resolve('<html lang="en"></html>');
});
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', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ export class Studio {
this.#localRepositoryRegistry,
this.#taskRegistry,
this.#inferenceManager,
this.#mcpServerManager,
this.#playgroundManager,
this.#snippetManager,
this.#cancellationTokenRegistry,
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<script lang="ts">
import { Button, Checkbox, Modal } from '@podman-desktop/ui-svelte';
import { faScrewdriverWrench } from '@fortawesome/free-solid-svg-icons';
import type { McpServer } from '@shared/models/McpSettings';

interface Props {
mcpServers: McpServer[];
}
let { mcpServers = [] }: Props = $props();
let open: boolean = $state(false);

const toggle = (): void => {
open = !open;
};

// TEMPORARY: Prevent tool unchecking
let checked = $state(true);
$effect(() => {
if (!checked) {
checked = true;
}
});
</script>

<div>
<Button type="secondary" icon={faScrewdriverWrench} padding="py-1 px-2" on:click={toggle} title="MCP Servers">
MCP Servers
</Button>
{#if open}
<Modal on:close={toggle}>
<div
data-testid="tool-selection-modal-tool-container"
class="text-[var(--pd-dropdown-item-text)] text-lg rounded-sm bg-[var(--pd-dropdown-bg)] divide-y divide-[var(--pd-dropdown-divider)]">
{#each mcpServers as server (server.name)}
<div
data-testid="tool-selection-modal-tool-item"
class="py-1 px-2 flex gap-1 items-center select-none hover:bg-[var(--pd-dropdown-item-hover-bg)] hover:text-[var(--pd-dropdown-item-hover-text)]">
<Checkbox bind:checked={checked} />
{server.name}
</div>
{/each}
</div>
</Modal>
{/if}
</div>
1 change: 1 addition & 0 deletions packages/frontend/src/pages/Playground.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
16 changes: 12 additions & 4 deletions packages/frontend/src/pages/Playground.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
Expand Down Expand Up @@ -343,6 +346,11 @@ function handleOnClick(): void {
</div>
</div>
</div>
{#if mcpServers.length > 0}
<div class="text-[var(--pd-content-card-text)] w-full">
<ToolSelectionModal mcpServers={mcpServers} />
</div>
{/if}
</svelte:fragment>
</ContentDetailsLayout>
</div>
Expand Down
30 changes: 30 additions & 0 deletions packages/frontend/src/stores/mcpSettings.ts
Original file line number Diff line number Diff line change
@@ -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<McpSettings> = RPCReadable<McpSettings>(
{
servers: {},
} as McpSettings,
MSG_MCP_SERVERS_UPDATE,
studioClient.getMcpSettings,
);
6 changes: 6 additions & 0 deletions packages/shared/src/StudioAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>('StudioAPI');
export interface StudioAPI {
Expand Down Expand Up @@ -175,6 +176,11 @@ export interface StudioAPI {
*/
getPlaygroundConversations(): Promise<Conversation[]>;

/**
* Returns the current MCP settings.
*/
getMcpSettings(): Promise<McpSettings>;

/**
* Get the extension configuration (preferences)
*/
Expand Down
Loading