Skip to content
Merged
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
65 changes: 65 additions & 0 deletions src/config/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ pub struct UiConfig {
/// Per-page visibility configuration.
#[serde(default)]
pub pages: PagesConfig,

/// MCP (Model Context Protocol) UI configuration.
#[serde(default)]
pub mcp: McpUiConfig,
}

impl Default for UiConfig {
Expand All @@ -44,10 +48,71 @@ impl Default for UiConfig {
admin: AdminConfig::default(),
branding: BrandingConfig::default(),
pages: PagesConfig::default(),
mcp: McpUiConfig::default(),
}
}
}

/// MCP (Model Context Protocol) UI configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct McpUiConfig {
/// Favorite MCP servers surfaced prominently in the catalog.
#[serde(default = "default_favorite_mcp_servers")]
pub favorites: Vec<FavoriteMcpServer>,
}

impl Default for McpUiConfig {
fn default() -> Self {
Self {
favorites: default_favorite_mcp_servers(),
}
}
}

/// A suggested MCP server shown in the catalog's "Favorites" section.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct FavoriteMcpServer {
/// Display name.
pub name: String,
/// Either a direct remote URL (`https://…`) the UI connects to, or a
/// registry identifier (e.g. `io.github.ScriptSmith/platter`) the UI
/// resolves against the public MCP registry.
pub url: String,
}

fn default_favorite_mcp_servers() -> Vec<FavoriteMcpServer> {
vec![
FavoriteMcpServer {
name: "Platter".into(),
url: "io.github.ScriptSmith/platter".into(),
},
FavoriteMcpServer {
name: "Atlassian".into(),
url: "https://mcp.atlassian.com/v1/mcp".into(),
},
FavoriteMcpServer {
name: "Notion".into(),
url: "https://mcp.notion.com/mcp".into(),
},
FavoriteMcpServer {
name: "Hugging Face".into(),
url: "https://huggingface.co/mcp".into(),
},
FavoriteMcpServer {
name: "Miro".into(),
url: "https://mcp.miro.com/".into(),
},
FavoriteMcpServer {
name: "Vercel".into(),
url: "https://mcp.vercel.com".into(),
},
]
}

fn default_true() -> bool {
true
}
Expand Down
48 changes: 48 additions & 0 deletions src/routes/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5779,6 +5779,54 @@ show_logo = false
assert_eq!(body["branding"]["login"]["show_logo"], false);
}

#[tokio::test]
async fn test_get_ui_config_mcp_favorites_default() {
let app = test_app().await;

let (status, body) = get_json(&app, "/admin/v1/ui/config").await;

assert_eq!(status, StatusCode::OK);
let favorites = body["mcp"]["favorites"]
.as_array()
.expect("favorites array");
let urls: Vec<&str> = favorites
.iter()
.map(|f| f["url"].as_str().unwrap())
.collect();
assert_eq!(urls.len(), 6);
assert!(urls.contains(&"io.github.ScriptSmith/platter"));
assert!(urls.contains(&"https://mcp.atlassian.com/v1/mcp"));
assert!(urls.contains(&"https://mcp.notion.com/mcp"));
assert!(urls.contains(&"https://huggingface.co/mcp"));
assert!(urls.contains(&"https://mcp.miro.com/"));
assert!(urls.contains(&"https://mcp.vercel.com"));
}

#[tokio::test]
async fn test_get_ui_config_mcp_favorites_custom() {
let config_str = format!(
r#"
{}

[[ui.mcp.favorites]]
name = "Internal Wiki"
url = "https://mcp.internal.example.com/mcp"
"#,
unique_db_config()
);

let app = test_app_with_config(&config_str).await;
let (status, body) = get_json(&app, "/admin/v1/ui/config").await;

assert_eq!(status, StatusCode::OK);
let favorites = body["mcp"]["favorites"]
.as_array()
.expect("favorites array");
assert_eq!(favorites.len(), 1);
assert_eq!(favorites[0]["name"], "Internal Wiki");
assert_eq!(favorites[0]["url"], "https://mcp.internal.example.com/mcp");
}

// ============================================================================
// Me (Self-Service) Tests
// ============================================================================
Expand Down
37 changes: 36 additions & 1 deletion src/routes/admin/ui_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ use crate::{
AppState,
config::{
AdminConfig, AdminPagesConfig, AuthMode, BrandingConfig, ChatConfig, ColorPalette,
CustomFont, FontsConfig, LoginConfig, PageConfig, PageStatus, PagesConfig, UiConfig,
CustomFont, FavoriteMcpServer, FontsConfig, LoginConfig, McpUiConfig, PageConfig,
PageStatus, PagesConfig, UiConfig,
},
};

Expand All @@ -18,6 +19,39 @@ pub struct UiConfigResponse {
pub auth: AuthResponse,
pub sovereignty: SovereigntyUiResponse,
pub pages: PagesResponse,
pub mcp: McpUiResponse,
}

#[derive(Debug, Serialize)]
pub struct McpUiResponse {
pub favorites: Vec<FavoriteMcpServerResponse>,
}

#[derive(Debug, Serialize)]
pub struct FavoriteMcpServerResponse {
pub name: String,
pub url: String,
}

impl From<&McpUiConfig> for McpUiResponse {
fn from(config: &McpUiConfig) -> Self {
Self {
favorites: config
.favorites
.iter()
.map(FavoriteMcpServerResponse::from)
.collect(),
}
}
}

impl From<&FavoriteMcpServer> for FavoriteMcpServerResponse {
fn from(entry: &FavoriteMcpServer) -> Self {
Self {
name: entry.name.clone(),
url: entry.url.clone(),
}
}
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -255,6 +289,7 @@ impl From<&UiConfig> for UiConfigResponse {
custom_fields: vec![],
},
pages: PagesResponse::from(&config.pages),
mcp: McpUiResponse::from(&config.mcp),
}
}
}
Expand Down
7 changes: 6 additions & 1 deletion ui/.storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from "react";
import { initialize, mswLoader } from "msw-storybook-addon";
import "../src/index.css";
import { PreferencesProvider } from "../src/preferences/PreferencesProvider";
import { ConfigProvider } from "../src/config/ConfigProvider";
import { defaultPreferences } from "../src/preferences/types";

// Initialize MSW
Expand Down Expand Up @@ -55,7 +56,11 @@ const preview: Preview = {
JSON.stringify({ ...defaultPreferences, theme })
);
}
return React.createElement(PreferencesProvider, null, React.createElement(Story));
return React.createElement(
ConfigProvider,
null,
React.createElement(PreferencesProvider, null, React.createElement(Story))
);
},
],
globalTypes: {
Expand Down
57 changes: 57 additions & 0 deletions ui/src/components/MCPConfigModal/MCPCatalog.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from "@storybook/react";

import { MCPCatalog } from "./MCPCatalog";

const meta = {
title: "Components/MCPCatalog",
component: MCPCatalog,
parameters: {
layout: "padded",
},
} satisfies Meta<typeof MCPCatalog>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Live: Story = {
args: {
onPick: (p) => alert(`Picked:\n${JSON.stringify(p, null, 2)}`),
onAddManual: () => alert("Add manually"),
onCancel: () => alert("Cancel"),
},
render: (args) => (
<div className="max-w-2xl space-y-4">
<p className="text-sm text-muted-foreground">
Live view of the catalog against <code>registry.modelcontextprotocol.io</code>. Try
searching for &ldquo;github&rdquo;, &ldquo;slack&rdquo;, or &ldquo;atlassian&rdquo;.
</p>
<MCPCatalog {...args} />
</div>
),
};

export const WithFavorites: Story = {
args: {
onPick: (p) => alert(`Picked:\n${JSON.stringify(p, null, 2)}`),
onAddManual: () => alert("Add manually"),
onCancel: () => alert("Cancel"),
favorites: [
{ name: "Platter", url: "io.github.ScriptSmith/platter" },
{ name: "Atlassian", url: "https://mcp.atlassian.com/v1/mcp" },
{ name: "Notion", url: "https://mcp.notion.com/mcp" },
{ name: "Hugging Face", url: "https://huggingface.co/mcp" },
{ name: "Miro", url: "https://mcp.miro.com/" },
{ name: "Vercel", url: "https://mcp.vercel.com" },
],
},
render: (args) => (
<div className="max-w-4xl space-y-4">
<p className="text-sm text-muted-foreground">
Catalog seeded with the default gateway-favorited servers. The Platter entry is a registry
identifier (resolved via <code>registry.modelcontextprotocol.io</code>), the others are
direct remote URLs.
</p>
<MCPCatalog {...args} />
</div>
),
};
Loading
Loading