From 4c5be4e807bc7d11b6490f9e69c886de297a322f Mon Sep 17 00:00:00 2001 From: Gabriel Gordon-Hall Date: Wed, 10 Sep 2025 10:39:45 +0100 Subject: [PATCH] feat: one click installation for popular MCP servers (#657) * backend configuration * frontend * fmt * adapt remote config * lock * opencode adapter --- crates/executors/default_mcp.json | 43 +++ crates/executors/src/executors/mod.rs | 21 +- crates/executors/src/mcp_config.rs | 221 +++++++++++++++- frontend/package-lock.json | 62 +++++ frontend/package.json | 1 + frontend/public/mcp/context7logo.png | Bin 0 -> 9380 bytes frontend/public/mcp/playwright_logo_icon.svg | 12 + frontend/src/components/ui/carousel.tsx | 260 +++++++++++++++++++ frontend/src/lib/mcp-strategies.ts | 29 +-- frontend/src/pages/settings/McpSettings.tsx | 149 ++++++++--- package.json | 1 + pnpm-lock.yaml | 28 ++ shared/types.ts | 2 +- 13 files changed, 755 insertions(+), 74 deletions(-) create mode 100644 crates/executors/default_mcp.json create mode 100644 frontend/public/mcp/context7logo.png create mode 100644 frontend/public/mcp/playwright_logo_icon.svg create mode 100644 frontend/src/components/ui/carousel.tsx diff --git a/crates/executors/default_mcp.json b/crates/executors/default_mcp.json new file mode 100644 index 00000000..cedf445f --- /dev/null +++ b/crates/executors/default_mcp.json @@ -0,0 +1,43 @@ +{ + "vibe_kanban": { + "command": "npx", + "args": [ + "-y", + "vibe-kanban", + "--mcp" + ] + }, + "context7": { + "type": "http", + "url": "https://mcp.context7.com/mcp", + "headers": { + "CONTEXT7_API_KEY": "YOUR_API_KEY" + } + }, + "playwright": { + "command": "npx", + "args": [ + "@playwright/mcp@latest" + ] + }, + "meta": { + "vibe_kanban": { + "name": "Vibe Kanban", + "description": "Create, update and delete Vibe Kanban tasks", + "url": "https://www.vibekanban.com/docs/user-guide/vibe-kanban-mcp-server", + "icon": "viba-kanban-favicon.png" + }, + "context7": { + "name": "Context7", + "description": "Fetch up-to-date documentation and code examples", + "url": "https://github.com/upstash/context7", + "icon": "mcp/context7logo.png" + }, + "playwright": { + "name": "Playwright", + "description": "Browser automation with Playwright", + "url": "https://github.com/microsoft/playwright-mcp", + "icon": "mcp/playwright_logo_icon.svg" + } + } +} diff --git a/crates/executors/src/executors/mod.rs b/crates/executors/src/executors/mod.rs index b7d12f8c..318151bf 100644 --- a/crates/executors/src/executors/mod.rs +++ b/crates/executors/src/executors/mod.rs @@ -85,10 +85,7 @@ impl CodingAgent { serde_json::json!({ "mcp_servers": {} }), - serde_json::json!({ - "command": "npx", - "args": ["-y", "vibe-kanban", "--mcp"], - }), + self.preconfigured_mcp(), true, ), Self::Amp(_) => McpConfig::new( @@ -96,10 +93,7 @@ impl CodingAgent { serde_json::json!({ "amp.mcpServers": {} }), - serde_json::json!({ - "command": "npx", - "args": ["-y", "vibe-kanban", "--mcp"], - }), + self.preconfigured_mcp(), false, ), Self::Opencode(_) => McpConfig::new( @@ -108,11 +102,7 @@ impl CodingAgent { "mcp": {}, "$schema": "https://opencode.ai/config.json" }), - serde_json::json!({ - "type": "local", - "command": ["npx", "-y", "vibe-kanban", "--mcp"], - "enabled": true - }), + self.preconfigured_mcp(), false, ), _ => McpConfig::new( @@ -120,10 +110,7 @@ impl CodingAgent { serde_json::json!({ "mcpServers": {} }), - serde_json::json!({ - "command": "npx", - "args": ["-y", "vibe-kanban", "--mcp"], - }), + self.preconfigured_mcp(), false, ), } diff --git a/crates/executors/src/mcp_config.rs b/crates/executors/src/mcp_config.rs index 9ba93998..745f45a8 100644 --- a/crates/executors/src/mcp_config.rs +++ b/crates/executors/src/mcp_config.rs @@ -2,21 +2,26 @@ //! //! These helpers abstract over JSON vs TOML formats used by different agents. -use std::collections::HashMap; +use std::{collections::HashMap, sync::LazyLock}; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Map, Value}; use tokio::fs; use ts_rs::TS; -use crate::executors::ExecutorError; +use crate::executors::{CodingAgent, ExecutorError}; + +static DEFAULT_MCP_JSON: &str = include_str!("../default_mcp.json"); +pub static PRECONFIGURED_MCP_SERVERS: LazyLock = LazyLock::new(|| { + serde_json::from_str::(DEFAULT_MCP_JSON).expect("Failed to parse default MCP JSON") +}); #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct McpConfig { servers: HashMap, pub servers_path: Vec, pub template: serde_json::Value, - pub vibe_kanban: serde_json::Value, + pub preconfigured: serde_json::Value, pub is_toml_config: bool, } @@ -24,14 +29,14 @@ impl McpConfig { pub fn new( servers_path: Vec, template: serde_json::Value, - vibe_kanban: serde_json::Value, + preconfigured: serde_json::Value, is_toml_config: bool, ) -> Self { Self { servers: HashMap::new(), servers_path, template, - vibe_kanban, + preconfigured, is_toml_config, } } @@ -79,3 +84,207 @@ pub async fn write_agent_config( } Ok(()) } + +type ServerMap = Map; + +fn is_http_server(s: &Map) -> bool { + matches!(s.get("type").and_then(Value::as_str), Some("http")) +} + +fn is_stdio(s: &Map) -> bool { + !is_http_server(s) && s.get("command").is_some() +} + +fn extract_meta(mut obj: ServerMap) -> (ServerMap, Option) { + let meta = obj.remove("meta"); + (obj, meta) +} + +fn attach_meta(mut obj: ServerMap, meta: Option) -> Value { + if let Some(m) = meta { + obj.insert("meta".to_string(), m); + } + Value::Object(obj) +} + +fn ensure_header(headers: &mut Map, key: &str, val: &str) { + match headers.get_mut(key) { + Some(Value::String(_)) => {} + _ => { + headers.insert(key.to_string(), Value::String(val.to_string())); + } + } +} + +fn transform_http_servers(mut servers: ServerMap, mut f: F) -> ServerMap +where + F: FnMut(Map) -> Map, +{ + for (_k, v) in servers.iter_mut() { + if let Value::Object(s) = v + && is_http_server(s) + { + let taken = std::mem::take(s); + *s = f(taken); + } + } + servers +} + +// --- Adapters --------------------------------------------------------------- + +fn adapt_passthrough(servers: ServerMap, meta: Option) -> Value { + attach_meta(servers, meta) +} + +fn adapt_gemini(servers: ServerMap, meta: Option) -> Value { + let servers = transform_http_servers(servers, |mut s| { + let url = s + .remove("url") + .unwrap_or_else(|| Value::String(String::new())); + let mut headers = s + .remove("headers") + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); + + ensure_header( + &mut headers, + "Accept", + "application/json, text/event-stream", + ); + Map::from_iter([ + ("httpUrl".to_string(), url), + ("headers".to_string(), Value::Object(headers)), + ]) + }); + attach_meta(servers, meta) +} + +fn adapt_cursor(servers: ServerMap, meta: Option) -> Value { + let servers = transform_http_servers(servers, |mut s| { + let url = s + .remove("url") + .unwrap_or_else(|| Value::String(String::new())); + let headers = s + .remove("headers") + .unwrap_or_else(|| Value::Object(Default::default())); + Map::from_iter([("url".to_string(), url), ("headers".to_string(), headers)]) + }); + attach_meta(servers, meta) +} + +fn adapt_codex(mut servers: ServerMap, mut meta: Option) -> Value { + servers.retain(|_, v| v.as_object().map(is_stdio).unwrap_or(false)); + + if let Some(Value::Object(ref mut m)) = meta { + m.retain(|k, _| servers.contains_key(k)); + servers.insert("meta".to_string(), Value::Object(std::mem::take(m))); + meta = None; // already attached above + } + attach_meta(servers, meta) +} + +fn adapt_opencode(servers: ServerMap, meta: Option) -> Value { + let mut servers = transform_http_servers(servers, |mut s| { + let url = s + .remove("url") + .unwrap_or_else(|| Value::String(String::new())); + + let mut headers = s + .remove("headers") + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); + + ensure_header( + &mut headers, + "Accept", + "application/json, text/event-stream", + ); + + Map::from_iter([ + ("type".to_string(), Value::String("remote".to_string())), + ("url".to_string(), url), + ("headers".to_string(), Value::Object(headers)), + ("enabled".to_string(), Value::Bool(true)), + ]) + }); + + for (_k, v) in servers.iter_mut() { + if let Value::Object(s) = v + && is_stdio(s) + { + let command_str = s + .remove("command") + .and_then(|v| match v { + Value::String(s) => Some(s), + _ => None, + }) + .unwrap_or_default(); + + let mut cmd_vec: Vec = Vec::new(); + if !command_str.is_empty() { + cmd_vec.push(Value::String(command_str)); + } + + if let Some(arr) = s.remove("args").and_then(|v| match v { + Value::Array(arr) => Some(arr), + _ => None, + }) { + for a in arr { + match a { + Value::String(s) => cmd_vec.push(Value::String(s)), + other => cmd_vec.push(other), // fall back to raw value if not string + } + } + } + + let mut new_map = Map::new(); + new_map.insert("type".to_string(), Value::String("local".to_string())); + new_map.insert("command".to_string(), Value::Array(cmd_vec)); + new_map.insert("enabled".to_string(), Value::Bool(true)); + *s = new_map; + } + } + + attach_meta(servers, meta) +} + +enum Adapter { + Passthrough, + Gemini, + Cursor, + Codex, + Opencode, +} + +fn apply_adapter(adapter: Adapter, canonical: Value) -> Value { + let (servers_only, meta) = match canonical.as_object() { + Some(map) => extract_meta(map.clone()), + None => (ServerMap::new(), None), + }; + + match adapter { + Adapter::Passthrough => adapt_passthrough(servers_only, meta), + Adapter::Gemini => adapt_gemini(servers_only, meta), + Adapter::Cursor => adapt_cursor(servers_only, meta), + Adapter::Codex => adapt_codex(servers_only, meta), + Adapter::Opencode => adapt_opencode(servers_only, meta), + } +} + +impl CodingAgent { + pub fn preconfigured_mcp(&self) -> Value { + use Adapter::*; + + let adapter = match self { + CodingAgent::ClaudeCode(_) | CodingAgent::Amp(_) => Passthrough, + CodingAgent::QwenCode(_) | CodingAgent::Gemini(_) => Gemini, + CodingAgent::Cursor(_) => Cursor, + CodingAgent::Codex(_) => Codex, + CodingAgent::Opencode(_) => Opencode, + }; + + let canonical = PRECONFIGURED_MCP_SERVERS.clone(); + apply_adapter(adapter, canonical) + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 745bd235..001dfe44 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-portal": "^1.1.9", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -1964,6 +1965,67 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5910b56a..cb0b68de 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,6 +43,7 @@ "click-to-react-component": "^1.1.2", "clsx": "^2.0.0", "diff": "^8.0.2", + "embla-carousel-react": "^8.6.0", "fancy-ansi": "^0.1.3", "lucide-react": "^0.539.0", "react": "^18.2.0", diff --git a/frontend/public/mcp/context7logo.png b/frontend/public/mcp/context7logo.png new file mode 100644 index 0000000000000000000000000000000000000000..43ad8207309c52991e231129ebd7c3d4584b18ab GIT binary patch literal 9380 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 + + Playwright Streamline Icon: https://streamlinehq.com + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/ui/carousel.tsx b/frontend/src/components/ui/carousel.tsx new file mode 100644 index 00000000..2abee3ee --- /dev/null +++ b/frontend/src/components/ui/carousel.tsx @@ -0,0 +1,260 @@ +import * as React from 'react'; +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from 'embla-carousel-react'; +import { ArrowLeft, ArrowRight } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +type CarouselProps = { + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: 'horizontal' | 'vertical'; + setApi?: (api: CarouselApi) => void; +}; + +type CarouselContextProps = { + carouselRef: ReturnType[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + + if (!context) { + throw new Error('useCarousel must be used within a '); + } + + return context; +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & CarouselProps +>( + ( + { + orientation = 'horizontal', + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === 'horizontal' ? 'x' : 'y', + }, + plugins + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) { + return; + } + + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); + + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'ArrowLeft') { + event.preventDefault(); + scrollPrev(); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext] + ); + + React.useEffect(() => { + if (!api || !setApi) { + return; + } + + setApi(api); + }, [api, setApi]); + + React.useEffect(() => { + if (!api) { + return; + } + + onSelect(api); + api.on('reInit', onSelect); + api.on('select', onSelect); + + return () => { + api?.off('select', onSelect); + }; + }, [api, onSelect]); + + return ( + +
+ {children} +
+
+ ); + } +); +Carousel.displayName = 'Carousel'; + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel(); + + return ( +
+
+
+ ); +}); +CarouselContent.displayName = 'CarouselContent'; + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel(); + + return ( +
+ ); +}); +CarouselItem.displayName = 'CarouselItem'; + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + + ); +}); +CarouselPrevious.displayName = 'CarouselPrevious'; + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +}); +CarouselNext.displayName = 'CarouselNext'; + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +}; diff --git a/frontend/src/lib/mcp-strategies.ts b/frontend/src/lib/mcp-strategies.ts index 9d7ad02f..21d3a022 100644 --- a/frontend/src/lib/mcp-strategies.ts +++ b/frontend/src/lib/mcp-strategies.ts @@ -53,32 +53,31 @@ export class McpConfigStrategyGeneral { return current; } - static addVibeKanbanToConfig( + static addPreconfiguredToConfig( mcp_config: McpConfig, - existingConfig: Record + existingConfig: Record, + serverKey: string ): Record { - // Clone the existing config to avoid mutations - const updatedConfig = JSON.parse(JSON.stringify(existingConfig)); - let current = updatedConfig; + const preconf = mcp_config.preconfigured as Record; + if (!preconf || typeof preconf !== 'object' || !(serverKey in preconf)) { + throw new Error(`Unknown preconfigured server '${serverKey}'`); + } + + const updated = JSON.parse(JSON.stringify(existingConfig || {})); + let current = updated; - // Navigate to the correct location for servers (all except the last element) for (let i = 0; i < mcp_config.servers_path.length - 1; i++) { const key = mcp_config.servers_path[i]; - if (!current[key]) { - current[key] = {}; - } + if (!current[key] || typeof current[key] !== 'object') current[key] = {}; current = current[key]; } - // Get or create the servers object at the final path element const lastKey = mcp_config.servers_path[mcp_config.servers_path.length - 1]; - if (!current[lastKey]) { + if (!current[lastKey] || typeof current[lastKey] !== 'object') current[lastKey] = {}; - } - // Add vibe_kanban server with the config from the schema - current[lastKey]['vibe_kanban'] = mcp_config.vibe_kanban; + current[lastKey][serverKey] = preconf[serverKey]; - return updatedConfig; + return updated; } } diff --git a/frontend/src/pages/settings/McpSettings.tsx b/frontend/src/pages/settings/McpSettings.tsx index 21120d01..1d318f4e 100644 --- a/frontend/src/pages/settings/McpSettings.tsx +++ b/frontend/src/pages/settings/McpSettings.tsx @@ -14,6 +14,13 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from '@/components/ui/carousel'; import { Label } from '@/components/ui/label'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { JSONEditor } from '@/components/ui/json-editor'; @@ -119,29 +126,6 @@ export function McpSettings() { } }; - const handleConfigureVibeKanban = async () => { - if (!selectedProfile || !mcpConfig) return; - - try { - // Parse existing configuration - const existingConfig = mcpServers.trim() ? JSON.parse(mcpServers) : {}; - - // Add vibe_kanban to the existing configuration using the schema - const updatedConfig = McpConfigStrategyGeneral.addVibeKanbanToConfig( - mcpConfig, - existingConfig - ); - - // Update the textarea with the new configuration - const configJson = JSON.stringify(updatedConfig, null, 2); - setMcpServers(configJson); - setMcpError(null); - } catch (err) { - setMcpError('Failed to configure vibe-kanban MCP server'); - console.error('Error configuring vibe-kanban:', err); - } - }; - const handleApplyMcpServers = async () => { if (!selectedProfile || !mcpConfig) return; @@ -200,6 +184,36 @@ export function McpSettings() { } }; + const addServer = (key: string) => { + try { + const existing = mcpServers.trim() ? JSON.parse(mcpServers) : {}; + const updated = McpConfigStrategyGeneral.addPreconfiguredToConfig( + mcpConfig!, + existing, + key + ); + setMcpServers(JSON.stringify(updated, null, 2)); + setMcpError(null); + } catch (err) { + console.error(err); + setMcpError( + err instanceof Error + ? err.message + : 'Failed to add preconfigured server' + ); + } + }; + + const preconfigured = (mcpConfig?.preconfigured ?? {}) as Record; + const meta = (preconfigured.meta ?? {}) as Record< + string, + { name?: string; description?: string; url?: string; icon?: string } + >; + const servers = Object.fromEntries( + Object.entries(preconfigured).filter(([k]) => k !== 'meta') + ) as Record; + const getMetaFor = (key: string) => meta[key] || {}; + if (!config) { return (
@@ -324,18 +338,83 @@ export function McpSettings() { )}
-
- -

- Automatically adds the Vibe-Kanban MCP server configuration. -

-
+ {mcpConfig?.preconfigured && + typeof mcpConfig.preconfigured === 'object' && ( +
+ +

+ Click a card to insert that MCP Server into the JSON + above. +

+ +
+ + + {Object.entries(servers).map(([key]) => { + const metaObj = getMetaFor(key) as { + name?: string; + description?: string; + url?: string; + icon?: string; + }; + const name = metaObj.name || key; + const description = + metaObj.description || 'No description'; + const icon = metaObj.icon + ? `/${metaObj.icon}` + : null; + + return ( + + + + ); + })} + + + + + +
+
+ )}
)} diff --git a/package.json b/package.json index c71c5386..79a23c6d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "npx-cli/dist/**" ], "scripts": { + "format": "cargo fmt --all && cd frontend && npm run format", "check": "npm run frontend:check && npm run backend:check", "dev": "export FRONTEND_PORT=$(node scripts/setup-dev-environment.js frontend) && export BACKEND_PORT=$(node scripts/setup-dev-environment.js backend) && concurrently \"npm run backend:dev:watch\" \"npm run frontend:dev\"", "test:npm": "./test-npm-package.sh", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2b78cd5..ec86c382 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: diff: specifier: ^8.0.2 version: 8.0.2 + embla-carousel-react: + specifier: ^8.6.0 + version: 8.6.0(react@18.3.1) fancy-ansi: specifier: ^0.1.3 version: 0.1.3 @@ -2000,6 +2003,19 @@ packages: electron-to-chromium@1.5.170: resolution: {integrity: sha512-GP+M7aeluQo9uAyiTCxgIj/j+PrWhMlY7LFVj8prlsPljd0Fdg9AprlfUi+OCSFWy9Y5/2D/Jrj9HS8Z4rpKWA==} + embla-carousel-react@8.6.0: + resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -5179,6 +5195,18 @@ snapshots: electron-to-chromium@1.5.170: {} + embla-carousel-react@8.6.0(react@18.3.1): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 18.3.1 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} diff --git a/shared/types.ts b/shared/types.ts index 32f22b19..968b3f1b 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -24,7 +24,7 @@ export type SearchMode = "taskform" | "settings"; export type ExecutorAction = { typ: ExecutorActionType, next_action: ExecutorAction | null, }; -export type McpConfig = { servers: { [key in string]?: JsonValue }, servers_path: Array, template: JsonValue, vibe_kanban: JsonValue, is_toml_config: boolean, }; +export type McpConfig = { servers: { [key in string]?: JsonValue }, servers_path: Array, template: JsonValue, preconfigured: JsonValue, is_toml_config: boolean, }; export type ExecutorActionType = { "type": "CodingAgentInitialRequest" } & CodingAgentInitialRequest | { "type": "CodingAgentFollowUpRequest" } & CodingAgentFollowUpRequest | { "type": "ScriptRequest" } & ScriptRequest;