feat: one click installation for popular MCP servers (#657)
* backend configuration * frontend * fmt * adapt remote config * lock * opencode adapter
This commit is contained in:
committed by
GitHub
parent
c79f0a200d
commit
4c5be4e807
260
frontend/src/components/ui/carousel.tsx
Normal file
260
frontend/src/components/ui/carousel.tsx
Normal 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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user