Skip to content

Commit c92bbfd

Browse files
committed
feat(playground): mcp servers listed
Signed-off-by: Marc Nuri <[email protected]>
1 parent bdc90f4 commit c92bbfd

File tree

9 files changed

+227
-13
lines changed

9 files changed

+227
-13
lines changed

packages/backend/src/studio-api-impl.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import type { RecipeManager } from './managers/recipes/RecipeManager';
4343
import type { PodmanConnection } from './managers/podmanConnection';
4444
import type { NavigationRegistry } from './registries/NavigationRegistry';
4545
import type { RpcExtension } from '@shared/messages/MessageProxy';
46+
import type { McpServerManager } from './managers/playground/McpServerManager';
4647

4748
vi.mock('./ai.json', () => {
4849
return {
@@ -103,6 +104,7 @@ let studioApiImpl: StudioApiImpl;
103104
let catalogManager: CatalogManager;
104105
let localRepositoryRegistry: LocalRepositoryRegistry;
105106
let applicationManager: ApplicationManager;
107+
let mcpServerManager: McpServerManager;
106108

107109
const podmanConnectionMock: PodmanConnection = {
108110
findRunningContainerProviderConnection: vi.fn(),
@@ -139,6 +141,10 @@ beforeEach(async () => {
139141
logError: vi.fn(),
140142
} as unknown as TelemetryLogger;
141143

144+
mcpServerManager = {
145+
getMcpSettings: vi.fn(),
146+
} as unknown as McpServerManager;
147+
142148
// Creating StudioApiImpl
143149
studioApiImpl = new StudioApiImpl(
144150
applicationManager,
@@ -148,6 +154,7 @@ beforeEach(async () => {
148154
localRepositoryRegistry,
149155
{} as unknown as TaskRegistry,
150156
{} as unknown as InferenceManager,
157+
mcpServerManager,
151158
{} as unknown as PlaygroundV2Manager,
152159
{} as unknown as SnippetManager,
153160
{} as unknown as CancellationTokenRegistry,
@@ -368,3 +375,8 @@ test('navigateToEditConnectionProvider should call navigation.navigateToEditProv
368375
await timeout(0);
369376
expect(navigationSpy).toHaveBeenCalledWith(connection);
370377
});
378+
379+
test('getMcpSettings should call mcpServerManager.getMcpSettings', async () => {
380+
await studioApiImpl.getMcpSettings();
381+
expect(mcpServerManager.getMcpSettings).toHaveBeenCalledOnce();
382+
});

packages/backend/src/studio-api-impl.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import type { StudioAPI } from '@shared/StudioAPI';
2020
import type { ApplicationManager } from './managers/application/applicationManager';
2121
import type { ModelInfo } from '@shared/models/IModelInfo';
22+
import type { McpSettings } from '@shared/models/McpSettings';
2223
import * as podmanDesktopApi from '@podman-desktop/api';
2324

2425
import type { CatalogManager } from './managers/catalogManager';
@@ -33,6 +34,7 @@ import path from 'node:path';
3334
import type { InferenceServer } from '@shared/models/IInference';
3435
import type { CreationInferenceServerOptions } from '@shared/models/InferenceServerConfig';
3536
import type { InferenceManager } from './managers/inference/inferenceManager';
37+
import type { McpServerManager } from './managers/playground/McpServerManager';
3638
import type { Conversation } from '@shared/models/IPlaygroundMessage';
3739
import type { PlaygroundV2Manager } from './managers/playgroundV2Manager';
3840
import { getFreeRandomPort } from './utils/ports';
@@ -71,6 +73,7 @@ export class StudioApiImpl implements StudioAPI {
7173
private localRepositories: LocalRepositoryRegistry,
7274
private taskRegistry: TaskRegistry,
7375
private inferenceManager: InferenceManager,
76+
private mcpServerManager: McpServerManager,
7477
private playgroundV2: PlaygroundV2Manager,
7578
private snippetManager: SnippetManager,
7679
private cancellationTokenRegistry: CancellationTokenRegistry,
@@ -119,6 +122,10 @@ export class StudioApiImpl implements StudioAPI {
119122
return this.playgroundV2.getConversations();
120123
}
121124

125+
async getMcpSettings(): Promise<McpSettings> {
126+
return this.mcpServerManager.getMcpSettings();
127+
}
128+
122129
async getExtensionConfiguration(): Promise<ExtensionConfiguration> {
123130
return this.configurationRegistry.getExtensionConfiguration();
124131
}

packages/backend/src/studio.spec.ts

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ vi.mock('@podman-desktop/api', async () => {
9090
containerEngine: {
9191
onEvent: vi.fn(),
9292
listContainers: mocks.listContainers,
93+
listPods: vi.fn(() => []),
9394
},
9495
navigation: {
9596
register: vi.fn(),
@@ -130,17 +131,52 @@ afterEach(() => {
130131
console.log = originalConsoleLog;
131132
});
132133

133-
test('check activate', async () => {
134-
expect(version).toBe('1.8.0');
135-
mocks.listContainers.mockReturnValue([]);
136-
mocks.getContainerConnections.mockReturnValue([]);
137-
(vi.spyOn(fs.promises, 'readFile') as unknown as MockInstance<() => Promise<string>>).mockImplementation(() => {
138-
return Promise.resolve('<html></html>');
134+
describe('activate', () => {
135+
beforeEach(async () => {
136+
expect(version).toBe('1.8.0');
137+
mocks.listContainers.mockReturnValue([]);
138+
mocks.getContainerConnections.mockReturnValue([]);
139+
(vi.spyOn(fs.promises, 'readFile') as unknown as MockInstance<() => Promise<string>>).mockImplementation(() => {
140+
return Promise.resolve('<html lang="en"></html>');
141+
});
142+
await studio.activate();
143+
});
144+
145+
test('logs the activation message', () => {
146+
// expect the activate method to be called on the studio class
147+
expect(mocks.consoleLogMock).toBeCalledWith('starting AI Lab extension');
139148
});
140-
await studio.activate();
141149

142-
// expect the activate method to be called on the studio class
143-
expect(mocks.consoleLogMock).toBeCalledWith('starting AI Lab extension');
150+
test.each([
151+
'RpcExtension',
152+
'NavigationRegistry',
153+
'CancellationTokenRegistry',
154+
'ConfigurationRegistry',
155+
'ContainerRegistry',
156+
'PodmanConnection',
157+
'TaskRegistry',
158+
'CatalogManager',
159+
'BuilderManager',
160+
'PodManager',
161+
'URLModelHandler',
162+
'HuggingFaceModelHandler',
163+
'ModelsManager',
164+
'LocalRepositoryRegistry',
165+
'GPUManager',
166+
'InferenceManager',
167+
'InstructlabManager',
168+
'LlamaStackManager',
169+
'RecipeManager',
170+
'ApplicationManager',
171+
'McpServerManager',
172+
'PlaygroundV2Manager',
173+
'SnippetManager',
174+
])('registers $0 as subscription', (manger: string) => {
175+
const subscriptions = mockedExtensionContext.subscriptions
176+
.filter(s => s?.constructor?.name)
177+
.map(s => s.constructor.name);
178+
expect(subscriptions).toContain(manger);
179+
});
144180
});
145181

146182
describe('version checker', () => {

packages/backend/src/studio.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ export class Studio {
213213
*/
214214
this.#catalogManager = new CatalogManager(this.#rpcExtension, appUserDirectory);
215215
this.#catalogManager.init();
216+
this.#extensionContext.subscriptions.push(this.#catalogManager);
216217

217218
/**
218219
* The builder manager is handling the building tasks, create corresponding tasks
@@ -385,6 +386,7 @@ export class Studio {
385386
*/
386387
this.#snippetManager = new SnippetManager(this.#rpcExtension, this.#telemetry);
387388
this.#snippetManager.init();
389+
this.#extensionContext.subscriptions.push(this.#snippetManager);
388390

389391
/**
390392
* The StudioApiImpl is the implementation of our API between backend and frontend
@@ -397,6 +399,7 @@ export class Studio {
397399
this.#localRepositoryRegistry,
398400
this.#taskRegistry,
399401
this.#inferenceManager,
402+
this.#mcpServerManager,
400403
this.#playgroundManager,
401404
this.#snippetManager,
402405
this.#cancellationTokenRegistry,
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**********************************************************************
2+
* Copyright (C) 2025 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
import { expect, test, beforeEach, describe, vi } from 'vitest';
19+
import '@testing-library/jest-dom/vitest';
20+
import { fireEvent, render, within } from '@testing-library/svelte';
21+
import type { McpServer } from '@shared/models/McpSettings';
22+
import ToolSelectionModal from '/@/lib/conversation/ToolSelectionModal.svelte';
23+
24+
let container: HTMLElement;
25+
26+
beforeEach(() => {
27+
vi.resetAllMocks();
28+
});
29+
30+
describe('with servers', () => {
31+
beforeEach(() => {
32+
container = render(ToolSelectionModal, {
33+
mcpServers: [{ name: 'server-1' } as unknown as McpServer, { name: 'server-2' } as unknown as McpServer],
34+
}).container;
35+
});
36+
describe('closed', () => {
37+
test('has button', () => {
38+
const button = within(container).getByTitle('MCP Servers');
39+
expect(button).toBeVisible();
40+
expect(button).toHaveTextContent('MCP Servers');
41+
});
42+
test('does not have modal', () => {
43+
const modal = within(container).queryByTestId('tool-selection-modal-tool-container');
44+
expect(modal).toBeNull();
45+
});
46+
});
47+
describe('open', () => {
48+
let button: HTMLElement;
49+
let modal: HTMLElement;
50+
beforeEach(async () => {
51+
button = within(container).getByTitle('MCP Servers');
52+
await fireEvent.click(button);
53+
modal = within(container).getByTestId('tool-selection-modal-tool-container');
54+
});
55+
test('has modal', () => {
56+
expect(modal).toBeVisible();
57+
});
58+
test('contains defined server entries', () => {
59+
const toolEntries = within(modal).getAllByTestId('tool-selection-modal-tool-item');
60+
expect(toolEntries).toHaveLength(2);
61+
});
62+
test.each(['server-1', 'server-2'])('has %s entry', name => {
63+
const serverEntry = within(modal).getByText(name);
64+
expect(serverEntry).toBeVisible();
65+
});
66+
});
67+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<script lang="ts">
2+
import { Button, Checkbox, Modal } from '@podman-desktop/ui-svelte';
3+
import { faScrewdriverWrench } from '@fortawesome/free-solid-svg-icons';
4+
import type { McpServer } from '@shared/models/McpSettings';
5+
6+
interface Props {
7+
mcpServers: McpServer[];
8+
}
9+
let { mcpServers = [] }: Props = $props();
10+
let open: boolean = $state(false);
11+
12+
const toggle = (): void => {
13+
open = !open;
14+
};
15+
16+
// TEMPORARY: Prevent tool unchecking
17+
let checked = $state(true);
18+
$effect(() => {
19+
if (!checked) {
20+
checked = true;
21+
}
22+
});
23+
</script>
24+
25+
<div>
26+
<Button type="secondary" icon={faScrewdriverWrench} padding="py-1 px-2" on:click={toggle} title="MCP Servers">
27+
MCP Servers
28+
</Button>
29+
{#if open}
30+
<Modal on:close={toggle}>
31+
<div
32+
data-testid="tool-selection-modal-tool-container"
33+
class="text-[var(--pd-dropdown-item-text)] text-lg rounded-sm bg-[var(--pd-dropdown-bg)] divide-y divide-[var(--pd-dropdown-divider)]">
34+
{#each mcpServers as server (server.name)}
35+
<div
36+
data-testid="tool-selection-modal-tool-item"
37+
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)]">
38+
<Checkbox bind:checked={checked} />
39+
{server.name}
40+
</div>
41+
{/each}
42+
</div>
43+
</Modal>
44+
{/if}
45+
</div>

packages/frontend/src/pages/Playground.svelte

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,22 @@ import {
1111
isAssistantToolCall,
1212
type Message,
1313
} from '@shared/models/IPlaygroundMessage';
14-
import { catalog } from '../stores/catalog';
14+
import { catalog } from '/@/stores/catalog';
15+
import { mcpSettings } from '/@/stores/mcpSettings';
1516
import ContentDetailsLayout from '../lib/ContentDetailsLayout.svelte';
1617
import RangeInput from '../lib/RangeInput.svelte';
1718
import Fa from 'svelte-fa';
1819
19-
import ChatMessage from '../lib/conversation/ChatMessage.svelte';
20+
import ChatMessage from '/@/lib/conversation/ChatMessage.svelte';
21+
import ConversationActions from '/@/lib/conversation/ConversationActions.svelte';
2022
import SystemPromptBanner from '/@/lib/conversation/SystemPromptBanner.svelte';
23+
import ToolCallMessage from '/@/lib/conversation/ToolCallMessage.svelte';
24+
import ToolSelectionModal from '/@/lib/conversation/ToolSelectionModal.svelte';
2125
import { inferenceServers } from '/@/stores/inferenceServers';
2226
import { faCircleInfo, faPaperPlane, faStop } from '@fortawesome/free-solid-svg-icons';
2327
import { Button, Tooltip, DetailsPage, StatusIcon } from '@podman-desktop/ui-svelte';
2428
import { router } from 'tinro';
25-
import ConversationActions from '../lib/conversation/ConversationActions.svelte';
2629
import { ContainerIcon } from '@podman-desktop/ui-svelte/icons';
27-
import ToolCallMessage from '/@/lib/conversation/ToolCallMessage.svelte';
2830
import type { InferenceServer } from '@shared/models/IInference';
2931
import type { ModelOptions } from '@shared/models/IModelOptions';
3032
@@ -40,6 +42,7 @@ let errorMsg = $state('');
4042
let cancellationTokenId: number | undefined = $state(undefined);
4143
4244
// settings
45+
let mcpServers = $derived(Object.values($mcpSettings.servers).filter(s => s.enabled === true));
4346
let temperature = $state(0.8);
4447
let max_tokens = $state(-1);
4548
let top_p = $state(0.5);
@@ -343,6 +346,11 @@ function handleOnClick(): void {
343346
</div>
344347
</div>
345348
</div>
349+
{#if mcpServers.length > 0}
350+
<div class="text-[var(--pd-content-card-text)] w-full">
351+
<ToolSelectionModal mcpServers={mcpServers} />
352+
</div>
353+
{/if}
346354
</svelte:fragment>
347355
</ContentDetailsLayout>
348356
</div>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**********************************************************************
2+
* Copyright (C) 2025 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
import { type Readable } from 'svelte/store';
19+
import type { McpSettings } from '@shared/models/McpSettings';
20+
import { MSG_MCP_SERVERS_UPDATE } from '@shared/Messages';
21+
import { RPCReadable } from '/@/stores/rpcReadable';
22+
import { studioClient } from '/@/utils/client';
23+
24+
export const mcpSettings: Readable<McpSettings> = RPCReadable<McpSettings>(
25+
{
26+
servers: {},
27+
} as McpSettings,
28+
MSG_MCP_SERVERS_UPDATE,
29+
studioClient.getMcpSettings,
30+
);

packages/shared/src/StudioAPI.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import type { ExtensionConfiguration } from './models/IExtensionConfiguration';
3838
import type { RecipePullOptions } from './models/IRecipe';
3939
import type { FilterRecipesResult, RecipeFilters } from './models/FilterRecipesResult';
4040
import { createRpcChannel } from './messages/MessageProxy';
41+
import type { McpSettings } from '@shared/models/McpSettings';
4142

4243
export const STUDIO_API_CHANNEL = createRpcChannel<StudioAPI>('StudioAPI');
4344
export interface StudioAPI {
@@ -175,6 +176,11 @@ export interface StudioAPI {
175176
*/
176177
getPlaygroundConversations(): Promise<Conversation[]>;
177178

179+
/**
180+
* Returns the current MCP settings.
181+
*/
182+
getMcpSettings(): Promise<McpSettings>;
183+
178184
/**
179185
* Get the extension configuration (preferences)
180186
*/

0 commit comments

Comments
 (0)