Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
51 changes: 49 additions & 2 deletions frontend/__tests__/routes/mcp-settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router";
import McpService from "#/api/mcp-service/mcp-service.api";
import OptionService from "#/api/option-service/option-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import {
MOCK_DEFAULT_USER_SETTINGS,
Expand All @@ -13,6 +14,7 @@ import MCPSettingsScreen, { clientLoader } from "#/routes/mcp-settings";
import { SUPER_ADMIN_SETUP_STEP_EVENT } from "#/components/features/setup/tours/types";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
import { Settings } from "#/types/settings";
import { createMockWebClientConfig } from "#/mocks/settings-handlers";

function buildSettings(overrides: Partial<Settings> = {}): Settings {
return {
Expand Down Expand Up @@ -64,7 +66,9 @@ function renderMcpSettingsScreen() {
return render(<MCPSettingsScreen />, {
wrapper: ({ children }) => (
<MemoryRouter>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</MemoryRouter>
),
});
Expand Down Expand Up @@ -158,7 +162,9 @@ describe("MCPSettingsScreen", () => {
);
}

for (const [key, value] of Object.entries(payload as Record<string, unknown>)) {
for (const [key, value] of Object.entries(
payload as Record<string, unknown>,
)) {
if (key === "agent_settings_diff") {
continue;
}
Expand Down Expand Up @@ -228,6 +234,47 @@ describe("MCPSettingsScreen", () => {
});
});

describe("MCPSettingsScreen with Agent Canvas", () => {
const mockConfig = (enabled: boolean) =>
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({
feature_flags: {
...createMockWebClientConfig().feature_flags,
enable_agent_canvas_mcp: enabled,
},
}),
);

it("shows Canvas's MCP page in place of the server editor and keeps the search key", async () => {
mockConfig(true);
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
buildSettings({ search_api_key: "tavily-key", search_api_key_set: true }),
);

renderMcpSettingsScreen();

const frame = await screen.findByTestId("mcp-canvas-frame");
expect(frame).toHaveAttribute("src", "/canvas/mcp?embed=1");
expect(screen.queryByTestId("mcp-server-list")).not.toBeInTheDocument();
expect(
screen.queryByText("SETTINGS$MCP_ADD_SERVER"),
).not.toBeInTheDocument();
expect(
await screen.findByTestId("mcp-search-settings-section"),
).toBeInTheDocument();
});

it("keeps the server editor when Canvas does not manage MCP servers", async () => {
mockConfig(false);
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(buildSettings());

renderMcpSettingsScreen();

expect(await screen.findByTestId("mcp-server-list")).toBeInTheDocument();
expect(screen.queryByTestId("mcp-canvas-frame")).not.toBeInTheDocument();
});
});

describe("MCPSettingsScreen connection test", () => {
function renderWithEmptyMcpConfig() {
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/api/option-service/option.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export interface WebClientFeatureFlags {
enable_onboarding: boolean;
enable_automations?: boolean;
enable_agent_canvas_banner?: boolean;
/** When true, Agent Canvas manages MCP servers on this install, so the
* classic MCP page frames Canvas's MCP page instead of its own editor. */
enable_agent_canvas_mcp?: boolean;
/** When true, LLM API key export (BYOR) is enabled for every org regardless
* of billing/credits. Mirrors the ENABLE_BYOR_EXPORT env var so the API keys
* page can distinguish "export disabled on this deployment" from "buy credits
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/mocks/settings-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,8 @@ export const SETTINGS_HANDLERS = [
enable_onboarding: false,
enable_automations: true,
enable_agent_canvas_banner: false,
enable_agent_canvas_mcp:
import.meta.env.VITE_MOCK_AGENT_CANVAS_MCP === "true",
enable_byor_export: false,
enable_super_admin: mockSaas,
},
Expand Down
48 changes: 32 additions & 16 deletions frontend/src/routes/mcp-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ function MCPSettingsScreen() {
const [serverToDelete, setServerToDelete] = useState<string | null>(null);

const isSaasMode = config?.app_mode === "saas";
// Where Agent Canvas is deployed it owns MCP server management; this page
// keeps only the settings Canvas has no home for.
const mcpServersInCanvas = !!config?.feature_flags?.enable_agent_canvas_mcp;
Comment thread
all-hands-bot marked this conversation as resolved.
Resolved

const mcpConfig: MCPConfig = parseMcpConfig(
settings?.agent_settings?.mcp_config,
Expand Down Expand Up @@ -242,24 +245,37 @@ function MCPSettingsScreen() {
{t(I18nKey.SETTINGS$MCP_DESCRIPTION)}
</p>
</header>
<BrandButton
type="button"
variant="primary"
className="shrink-0 whitespace-nowrap"
onClick={() => {
resetTest();
setView("add");
}}
>
{t(I18nKey.SETTINGS$MCP_ADD_SERVER)}
</BrandButton>
{!mcpServersInCanvas && (
<BrandButton
type="button"
variant="primary"
className="shrink-0 whitespace-nowrap"
onClick={() => {
resetTest();
setView("add");
}}
>
{t(I18nKey.SETTINGS$MCP_ADD_SERVER)}
</BrandButton>
)}
</div>

<MCPServerList
servers={allServers}
onEdit={handleEditClick}
onDelete={handleDeleteClick}
/>
{mcpServersInCanvas ? (
// Canvas's own MCP page, without its chrome (`?embed=1`). Same
// origin, so it shares this session.
<iframe
data-testid="mcp-canvas-frame"
src="/canvas/mcp?embed=1"
title={t(I18nKey.SETTINGS$MCP_TITLE)}
className="h-[calc(100vh-12rem)] min-h-[32rem] w-full rounded-xl border border-[var(--oh-border)] bg-base-secondary"
/>
) : (
<MCPServerList
servers={allServers}
onEdit={handleEditClick}
onDelete={handleDeleteClick}
/>
)}

{!isSaasMode ? (
<section
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ def _get_feature_flags() -> WebClientFeatureFlags:
enable_onboarding=os.getenv('OH_ENABLE_ONBOARDING', 'false') == 'true',
enable_automations=os.getenv('ENABLE_AUTOMATIONS', 'true') == 'true',
enable_agent_canvas_banner=_env_flag_enabled('ENABLE_AGENT_CANVAS_BANNER'),
enable_agent_canvas_mcp=_env_flag_enabled('ENABLE_AGENT_CANVAS_MCP'),
enable_byor_export=_env_flag_enabled('ENABLE_BYOR_EXPORT'),
enable_super_admin=_env_flag_enabled('ENABLE_SUPER_ADMIN'),
enable_oauth_v2_login=_env_flag_enabled('ENABLE_OAUTH_V2_LOGIN'),
Expand Down
6 changes: 6 additions & 0 deletions openhands/app_server/web_client/web_client_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ class WebClientFeatureFlags(BaseModel):
# OH_WEB_CLIENT_FEATURE_FLAGS_* form, which the server reads too). Default
# False so existing installs keep today's behavior.
enable_model_router: bool = False
# When true, Agent Canvas is deployed alongside this app and owns MCP server
# management: the classic MCP settings page shows Canvas's MCP page in a
# frame instead of its own server editor. Set from ENABLE_AGENT_CANVAS_MCP
# (the OHE chart sets it whenever agent-canvas is enabled). Default False so
# SaaS and installs without Canvas keep the classic editor.
enable_agent_canvas_mcp: bool = False

# This can be removed / replaced when a DeploymentMode (or similar) env var is created.
@model_validator(mode='after')
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/app_server/test_default_web_client_config_injector.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,26 @@ def test_enable_agent_canvas_banner_true_from_structured_env(self):

assert config.feature_flags.enable_agent_canvas_banner is True

def test_enable_agent_canvas_mcp_false_by_default(self):
"""Without ENABLE_AGENT_CANVAS_MCP the classic MCP editor stays."""
from openhands.app_server.web_client.default_web_client_config_injector import (
_get_feature_flags,
)

with patch.dict(os.environ, {}, clear=True):
result = _get_feature_flags()
assert result.enable_agent_canvas_mcp is False

def test_enable_agent_canvas_mcp_true_when_env_var_true(self):
"""ENABLE_AGENT_CANVAS_MCP=true hands MCP server management to Canvas."""
from openhands.app_server.web_client.default_web_client_config_injector import (
_get_feature_flags,
)

with patch.dict(os.environ, {'ENABLE_AGENT_CANVAS_MCP': 'true'}):
result = _get_feature_flags()
assert result.enable_agent_canvas_mcp is True

def test_enable_super_admin_false_by_default(self):
"""When ENABLE_SUPER_ADMIN is unset, the Super Admin flag is False."""
from openhands.app_server.web_client.default_web_client_config_injector import (
Expand Down
Loading