feat: one click installation for popular MCP servers (#657)

* backend configuration

* frontend

* fmt

* adapt remote config

* lock

* opencode adapter
This commit is contained in:
Gabriel Gordon-Hall
2025-09-10 10:39:45 +01:00
committed by GitHub
parent c79f0a200d
commit 4c5be4e807
13 changed files with 755 additions and 74 deletions

View File

@@ -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"
}
}
}

View File

@@ -85,10 +85,7 @@ impl CodingAgent {
serde_json::json!({ serde_json::json!({
"mcp_servers": {} "mcp_servers": {}
}), }),
serde_json::json!({ self.preconfigured_mcp(),
"command": "npx",
"args": ["-y", "vibe-kanban", "--mcp"],
}),
true, true,
), ),
Self::Amp(_) => McpConfig::new( Self::Amp(_) => McpConfig::new(
@@ -96,10 +93,7 @@ impl CodingAgent {
serde_json::json!({ serde_json::json!({
"amp.mcpServers": {} "amp.mcpServers": {}
}), }),
serde_json::json!({ self.preconfigured_mcp(),
"command": "npx",
"args": ["-y", "vibe-kanban", "--mcp"],
}),
false, false,
), ),
Self::Opencode(_) => McpConfig::new( Self::Opencode(_) => McpConfig::new(
@@ -108,11 +102,7 @@ impl CodingAgent {
"mcp": {}, "mcp": {},
"$schema": "https://opencode.ai/config.json" "$schema": "https://opencode.ai/config.json"
}), }),
serde_json::json!({ self.preconfigured_mcp(),
"type": "local",
"command": ["npx", "-y", "vibe-kanban", "--mcp"],
"enabled": true
}),
false, false,
), ),
_ => McpConfig::new( _ => McpConfig::new(
@@ -120,10 +110,7 @@ impl CodingAgent {
serde_json::json!({ serde_json::json!({
"mcpServers": {} "mcpServers": {}
}), }),
serde_json::json!({ self.preconfigured_mcp(),
"command": "npx",
"args": ["-y", "vibe-kanban", "--mcp"],
}),
false, false,
), ),
} }

View File

@@ -2,21 +2,26 @@
//! //!
//! These helpers abstract over JSON vs TOML formats used by different agents. //! 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::{Deserialize, Serialize};
use serde_json::Value; use serde_json::{Map, Value};
use tokio::fs; use tokio::fs;
use ts_rs::TS; 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<Value> = LazyLock::new(|| {
serde_json::from_str::<Value>(DEFAULT_MCP_JSON).expect("Failed to parse default MCP JSON")
});
#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct McpConfig { pub struct McpConfig {
servers: HashMap<String, serde_json::Value>, servers: HashMap<String, serde_json::Value>,
pub servers_path: Vec<String>, pub servers_path: Vec<String>,
pub template: serde_json::Value, pub template: serde_json::Value,
pub vibe_kanban: serde_json::Value, pub preconfigured: serde_json::Value,
pub is_toml_config: bool, pub is_toml_config: bool,
} }
@@ -24,14 +29,14 @@ impl McpConfig {
pub fn new( pub fn new(
servers_path: Vec<String>, servers_path: Vec<String>,
template: serde_json::Value, template: serde_json::Value,
vibe_kanban: serde_json::Value, preconfigured: serde_json::Value,
is_toml_config: bool, is_toml_config: bool,
) -> Self { ) -> Self {
Self { Self {
servers: HashMap::new(), servers: HashMap::new(),
servers_path, servers_path,
template, template,
vibe_kanban, preconfigured,
is_toml_config, is_toml_config,
} }
} }
@@ -79,3 +84,207 @@ pub async fn write_agent_config(
} }
Ok(()) Ok(())
} }
type ServerMap = Map<String, Value>;
fn is_http_server(s: &Map<String, Value>) -> bool {
matches!(s.get("type").and_then(Value::as_str), Some("http"))
}
fn is_stdio(s: &Map<String, Value>) -> bool {
!is_http_server(s) && s.get("command").is_some()
}
fn extract_meta(mut obj: ServerMap) -> (ServerMap, Option<Value>) {
let meta = obj.remove("meta");
(obj, meta)
}
fn attach_meta(mut obj: ServerMap, meta: Option<Value>) -> Value {
if let Some(m) = meta {
obj.insert("meta".to_string(), m);
}
Value::Object(obj)
}
fn ensure_header(headers: &mut Map<String, Value>, 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<F>(mut servers: ServerMap, mut f: F) -> ServerMap
where
F: FnMut(Map<String, Value>) -> Map<String, Value>,
{
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>) -> Value {
attach_meta(servers, meta)
}
fn adapt_gemini(servers: ServerMap, meta: Option<Value>) -> 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>) -> 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>) -> 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>) -> 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<Value> = 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)
}
}

View File

@@ -20,6 +20,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-portal": "^1.1.9", "@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-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@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": { "node_modules/@radix-ui/react-select": {
"version": "2.2.5", "version": "2.2.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz",

View File

@@ -43,6 +43,7 @@
"click-to-react-component": "^1.1.2", "click-to-react-component": "^1.1.2",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"diff": "^8.0.2", "diff": "^8.0.2",
"embla-carousel-react": "^8.6.0",
"fancy-ansi": "^0.1.3", "fancy-ansi": "^0.1.3",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"react": "^18.2.0", "react": "^18.2.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" id="Playwright--Streamline-Svg-Logos" height="24" width="24">
<desc>
Playwright Streamline Icon: https://streamlinehq.com
</desc>
<path fill="#2d4552" d="M7.99585 13.141725c-0.87725 0.248975 -1.452775 0.685475 -1.8319 1.12165 0.363125 -0.317775 0.849525 -0.609425 1.505675 -0.795425 0.6711 -0.1902 1.243625 -0.188825 1.7167 -0.09755v-0.369925c-0.40355 -0.0369 -0.866225 -0.0075 -1.390475 0.14125Zm-1.872 -3.109775 -3.25795 0.858325s0.059375 0.083875 0.1693 0.195775l2.76235 -0.727875s-0.03915 0.5044 -0.379075 0.9556c0.643 -0.486475 0.705375 -1.281825 0.705375 -1.281825Zm2.727125 7.65675C4.26615 18.923575 1.8404825 13.61025 1.1060875 10.852425c-0.3393 -1.273 -0.487415 -2.2371 -0.5268925 -2.859275 -0.0042425 -0.0646 -0.0022825 -0.11905 0.002285 -0.16895 -0.237835 0.01435 -0.3517015 0.137975 -0.328535 0.49525 0.0394775 0.621825 0.187595 1.585875 0.526895 2.859275C1.5139075 13.936125 3.9399 19.24945 8.52475 18.0146c0.99795 -0.26885 1.747675 -0.758525 2.310475 -1.383625 -0.51875 0.468525 -1.168 0.8375 -1.98425 1.057725Zm0.861575 -10.90855v0.3263h1.79835c-0.0369 -0.115525 -0.074075 -0.219625 -0.110975 -0.3263h-1.687375Z" stroke-width="0.25"></path>
<path fill="#2d4552" d="M11.9129 9.46735c0.80875 0.229675 1.2365 0.7967 1.462575 1.2985l0.90175 0.2561s-0.123 -1.756175 -1.711525 -2.2074c-1.486075 -0.422225 -2.400575 0.8257 -2.5118 0.9872 0.4323 -0.308 1.063575 -0.56015 1.859 -0.3344Zm7.178175 1.3066c-1.487425 -0.424125 -2.401575 0.8264 -2.511175 0.985625 0.432625 -0.307625 1.063575 -0.559875 1.85865 -0.3331 0.80745 0.23005 1.23485 0.796375 1.461625 1.298525l0.90305 0.25705s-0.125 -1.756525 -1.71215 -2.2081Zm-0.8959 4.6305 -7.501475 -2.097125s0.0812 0.411725 0.3928 0.94485l6.3159 1.765675c0.519975 -0.30085 0.792775 -0.6134 0.792775 -0.6134ZM12.994375 19.918475C7.054675 18.326 7.77275 10.758025 8.733875 7.171825c0.395725 -1.4779 0.802575 -2.576375 1.13995 -3.312725 -0.2013 -0.041425 -0.368025 0.0646 -0.532775 0.39965 -0.358225 0.726575 -0.8163 1.90955 -1.259625 3.5656 -0.96085 3.586125 -1.67895 11.15385 4.2605 12.746325 2.79955 0.75 4.980475 -0.3899 6.60625 -2.18005 -1.543175 1.3977 -3.513425 2.181325 -5.9538 1.52785Z" stroke-width="0.25"></path>
<path fill="#e2574c" d="M9.7126 15.915175V14.388l-4.243175 1.2032s0.313525 -1.82175 2.526475 -2.4495c0.6711 -0.1902 1.2437 -0.1889 1.7167 -0.09755V6.780125h2.124575c-0.231325 -0.714825 -0.4551 -1.26515 -0.64305 -1.64755 -0.310925 -0.632925 -0.62965 -0.21335 -1.35325 0.39185 -0.50965 0.425775 -1.797675 1.33405 -3.7359 1.85635 -1.938275 0.522625 -3.50525 0.384025 -4.15906 0.2708 -0.9268825 -0.1599 -1.4116925 -0.36345 -1.3663375 0.34155 0.03947 0.621825 0.187595 1.58595 0.5268925 2.859275C1.8405325 13.609875 4.266525 18.9232 8.85135 17.68835c1.197625 -0.3227 2.04295 -0.960525 2.6289 -1.7735h-1.76765v0.000325ZM2.865625 10.89025l3.258275 -0.858325s-0.094975 1.25345 -1.31645 1.57545c-1.2218 0.321675 -1.941825 -0.717125 -1.941825 -0.717125Z" stroke-width="0.25"></path>
<path fill="#2ead33" d="M21.975075 6.8525c-0.84695 0.148475 -2.878875 0.33345 -5.389975 -0.339625 -2.5118 -0.672675 -4.17835 -1.849175 -4.838625 -2.402175 -0.936 -0.783975 -1.347725 -1.328825 -1.752925 -0.5047 -0.358225 0.726875 -0.816325 1.909875 -1.259725 3.565925 -0.960775 3.586125 -1.67885 11.15385 4.260525 12.7463 5.938125 1.591125 9.09945 -5.322175 10.0603 -8.908625 0.4434 -1.655725 0.637825 -2.9095 0.691325 -3.717925 0.061 -0.915775 -0.568025 -0.64995 -1.7709 -0.439175ZM10.0418 9.81945s0.936 -1.45575 2.523525 -1.00455c1.588525 0.451225 1.711525 2.207425 1.711525 2.207425l-4.23505 -1.202875ZM13.917 16.352c-2.79235 -0.817975 -3.223 -3.04465 -3.223 -3.04465l7.501125 2.0972c0 -0.00035 -1.5141 1.755175 -4.278125 0.94745Zm2.6521 -4.57605s0.9347 -1.45475 2.521925 -1.00225c1.587175 0.4519 1.71215 2.2081 1.71215 2.2081l-4.234075 -1.20585Z" stroke-width="0.25"></path>
<path fill="#d65348" d="M8.2299 14.808525 5.4695 15.590875s0.29985 -1.708225 2.33335 -2.385175l-1.563075 -5.865975 -0.135075 0.04105c-1.93825 0.5227 -3.505225 0.384025 -4.15903 0.2708 -0.9268775 -0.159825 -1.411685 -0.36345 -1.3663375 0.341625 0.0394775 0.621825 0.187595 1.585875 0.5268925 2.85925 0.7340675 2.757425 3.160075 8.07075 7.744875 6.8359l0.135075 -0.042425 -0.756275 -2.8374ZM2.8657 10.8903l3.258275 -0.858375s-0.094975 1.25345 -1.316425 1.57545c-1.221825 0.321675 -1.94185 -0.717075 -1.94185 -0.717075Z" stroke-width="0.25"></path>
<path fill="#1d8d22" d="m14.04295 16.382625 -0.1263 -0.0307c-2.792325 -0.8179 -3.223 -3.044575 -3.223 -3.044575l3.86805 1.0812 2.047825 -7.86915 -0.024775 -0.006525c-2.5118 -0.672675 -4.17825 -1.849175 -4.838625 -2.402175 -0.936 -0.783975 -1.347725 -1.328825 -1.752925 -0.5047 -0.357875 0.726875 -0.815975 1.909875 -1.259375 3.565925 -0.960775 3.586125 -1.67885 11.15385 4.260525 12.74625l0.121725 0.027425 0.926875 -3.562975ZM10.0418 9.819475s0.936 -1.455775 2.523525 -1.004575c1.588525 0.451225 1.711525 2.207425 1.711525 2.207425l-4.23505 -1.20285Z" stroke-width="0.25"></path>
<path fill="#c04b41" d="m8.37055 14.7683 -0.740275 0.2101c0.174875 0.9859 0.483125 1.93205 0.966975 2.7679 0.0842 -0.0186 0.167725 -0.034575 0.2535 -0.058075 0.2248 -0.06065 0.43325 -0.13575 0.63395 -0.21765 -0.5406 -0.802225 -0.898225 -1.726175 -1.11415 -2.702275Zm-0.289075 -6.9439c-0.3804 1.419825 -0.720725 3.46345 -0.62705 5.51325 0.167675 -0.072775 0.3448 -0.140575 0.54155 -0.1964l0.13705 -0.030625c-0.167075 -2.189525 0.194075 -4.4207 0.600925 -5.93875 0.103125 -0.384025 0.206525 -0.741225 0.3096 -1.07435 -0.166025 0.105675 -0.3448 0.213975 -0.548425 0.32555 -0.137325 0.42385 -0.276 0.887125 -0.41365 1.401325Z" stroke-width="0.25"></path>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -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<typeof useEmblaCarousel>;
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<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />');
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & 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<HTMLDivElement>) => {
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 (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
);
Carousel.displayName = 'Carousel';
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = 'CarouselContent';
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className
)}
{...props}
/>
);
});
CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-left-12 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
});
CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
});
CarouselNext.displayName = 'CarouselNext';
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View File

@@ -53,32 +53,31 @@ export class McpConfigStrategyGeneral {
return current; return current;
} }
static addVibeKanbanToConfig( static addPreconfiguredToConfig(
mcp_config: McpConfig, mcp_config: McpConfig,
existingConfig: Record<string, any> existingConfig: Record<string, any>,
serverKey: string
): Record<string, any> { ): Record<string, any> {
// Clone the existing config to avoid mutations const preconf = mcp_config.preconfigured as Record<string, any>;
const updatedConfig = JSON.parse(JSON.stringify(existingConfig)); if (!preconf || typeof preconf !== 'object' || !(serverKey in preconf)) {
let current = updatedConfig; 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++) { for (let i = 0; i < mcp_config.servers_path.length - 1; i++) {
const key = mcp_config.servers_path[i]; const key = mcp_config.servers_path[i];
if (!current[key]) { if (!current[key] || typeof current[key] !== 'object') current[key] = {};
current[key] = {};
}
current = 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]; const lastKey = mcp_config.servers_path[mcp_config.servers_path.length - 1];
if (!current[lastKey]) { if (!current[lastKey] || typeof current[lastKey] !== 'object')
current[lastKey] = {}; current[lastKey] = {};
}
// Add vibe_kanban server with the config from the schema current[lastKey][serverKey] = preconf[serverKey];
current[lastKey]['vibe_kanban'] = mcp_config.vibe_kanban;
return updatedConfig; return updated;
} }
} }

View File

@@ -14,6 +14,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { JSONEditor } from '@/components/ui/json-editor'; 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 () => { const handleApplyMcpServers = async () => {
if (!selectedProfile || !mcpConfig) return; 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<string, any>;
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<string, any>;
const getMetaFor = (key: string) => meta[key] || {};
if (!config) { if (!config) {
return ( return (
<div className="py-8"> <div className="py-8">
@@ -324,18 +338,83 @@ export function McpSettings() {
)} )}
</div> </div>
<div className="pt-4"> {mcpConfig?.preconfigured &&
<Button typeof mcpConfig.preconfigured === 'object' && (
onClick={handleConfigureVibeKanban} <div className="pt-4">
disabled={mcpApplying || mcpLoading || !selectedProfile} <Label>Popular servers</Label>
className="w-64" <p className="text-sm text-muted-foreground mb-2">
> Click a card to insert that MCP Server into the JSON
Add Vibe-Kanban MCP above.
</Button> </p>
<p className="text-sm text-muted-foreground mt-2">
Automatically adds the Vibe-Kanban MCP server configuration. <div className="relative overflow-hidden rounded-xl border bg-background">
</p> <Carousel className="w-full px-4 py-3">
</div> <CarouselContent className="gap-3 justify-center">
{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 (
<CarouselItem
key={name}
className="sm:basis-1/3 lg:basis-1/4"
>
<button
type="button"
onClick={() => addServer(key)}
aria-label={`Add ${name} to config`}
className="group w-full text-left outline-none"
>
<Card className="h-32 rounded-xl border hover:shadow-md transition">
<CardHeader className="pb-0">
<div className="flex items-center gap-3">
<div className="w-6 h-6 rounded-lg border bg-muted grid place-items-center overflow-hidden">
{icon ? (
<img
src={icon}
alt=""
className="w-full h-full object-cover"
/>
) : (
<span className="font-semibold">
{name.slice(0, 1).toUpperCase()}
</span>
)}
</div>
<CardTitle className="text-base font-medium truncate">
{name}
</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-2 px-4">
<p className="text-sm text-muted-foreground line-clamp-3">
{description}
</p>
</CardContent>
</Card>
</button>
</CarouselItem>
);
})}
</CarouselContent>
<CarouselPrevious className="left-2 top-1/2 -translate-y-1/2 h-8 w-8 rounded-full border bg-background/80 shadow-sm backdrop-blur hover:bg-background" />
<CarouselNext className="right-2 top-1/2 -translate-y-1/2 h-8 w-8 rounded-full border bg-background/80 shadow-sm backdrop-blur hover:bg-background" />
</Carousel>
</div>
</div>
)}
</div> </div>
)} )}
</CardContent> </CardContent>

View File

@@ -10,6 +10,7 @@
"npx-cli/dist/**" "npx-cli/dist/**"
], ],
"scripts": { "scripts": {
"format": "cargo fmt --all && cd frontend && npm run format",
"check": "npm run frontend:check && npm run backend:check", "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\"", "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", "test:npm": "./test-npm-package.sh",

28
pnpm-lock.yaml generated
View File

@@ -111,6 +111,9 @@ importers:
diff: diff:
specifier: ^8.0.2 specifier: ^8.0.2
version: 8.0.2 version: 8.0.2
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0(react@18.3.1)
fancy-ansi: fancy-ansi:
specifier: ^0.1.3 specifier: ^0.1.3
version: 0.1.3 version: 0.1.3
@@ -2000,6 +2003,19 @@ packages:
electron-to-chromium@1.5.170: electron-to-chromium@1.5.170:
resolution: {integrity: sha512-GP+M7aeluQo9uAyiTCxgIj/j+PrWhMlY7LFVj8prlsPljd0Fdg9AprlfUi+OCSFWy9Y5/2D/Jrj9HS8Z4rpKWA==} 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: emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -5179,6 +5195,18 @@ snapshots:
electron-to-chromium@1.5.170: {} 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@8.0.0: {}
emoji-regex@9.2.2: {} emoji-regex@9.2.2: {}

View File

@@ -24,7 +24,7 @@ export type SearchMode = "taskform" | "settings";
export type ExecutorAction = { typ: ExecutorActionType, next_action: ExecutorAction | null, }; export type ExecutorAction = { typ: ExecutorActionType, next_action: ExecutorAction | null, };
export type McpConfig = { servers: { [key in string]?: JsonValue }, servers_path: Array<string>, template: JsonValue, vibe_kanban: JsonValue, is_toml_config: boolean, }; export type McpConfig = { servers: { [key in string]?: JsonValue }, servers_path: Array<string>, template: JsonValue, preconfigured: JsonValue, is_toml_config: boolean, };
export type ExecutorActionType = { "type": "CodingAgentInitialRequest" } & CodingAgentInitialRequest | { "type": "CodingAgentFollowUpRequest" } & CodingAgentFollowUpRequest | { "type": "ScriptRequest" } & ScriptRequest; export type ExecutorActionType = { "type": "CodingAgentInitialRequest" } & CodingAgentInitialRequest | { "type": "CodingAgentFollowUpRequest" } & CodingAgentFollowUpRequest | { "type": "ScriptRequest" } & ScriptRequest;