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,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;
}
static addVibeKanbanToConfig(
static addPreconfiguredToConfig(
mcp_config: McpConfig,
existingConfig: Record<string, any>
existingConfig: Record<string, any>,
serverKey: string
): Record<string, any> {
// Clone the existing config to avoid mutations
const updatedConfig = JSON.parse(JSON.stringify(existingConfig));
let current = updatedConfig;
const preconf = mcp_config.preconfigured as Record<string, any>;
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;
}
}

View File

@@ -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<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) {
return (
<div className="py-8">
@@ -324,18 +338,83 @@ export function McpSettings() {
)}
</div>
<div className="pt-4">
<Button
onClick={handleConfigureVibeKanban}
disabled={mcpApplying || mcpLoading || !selectedProfile}
className="w-64"
>
Add Vibe-Kanban MCP
</Button>
<p className="text-sm text-muted-foreground mt-2">
Automatically adds the Vibe-Kanban MCP server configuration.
</p>
</div>
{mcpConfig?.preconfigured &&
typeof mcpConfig.preconfigured === 'object' && (
<div className="pt-4">
<Label>Popular servers</Label>
<p className="text-sm text-muted-foreground mb-2">
Click a card to insert that MCP Server into the JSON
above.
</p>
<div className="relative overflow-hidden rounded-xl border bg-background">
<Carousel className="w-full px-4 py-3">
<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>
)}
</CardContent>