Some checks are pending
Build Test / Build Test (${{ matrix.platform.name }}) (map[name:Linux os:ubuntu-latest rust-target:x86_64-unknown-linux-gnu]) (push) Waiting to run
Build Test / Build Test (${{ matrix.platform.name }}) (map[name:Windows os:windows-latest rust-target:x86_64-pc-windows-msvc]) (push) Waiting to run
Build Test / Build Test (${{ matrix.platform.name }}) (map[name:macOS os:macos-latest rust-target:x86_64-apple-darwin]) (push) Waiting to run
Build Test / Build Test Summary (push) Blocked by required conditions
449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
import React, { useState } from "react";
|
||
import { Plus, Terminal, Globe, Trash2, Info, Loader2 } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||
import { SelectComponent } from "@/components/ui/select";
|
||
import { Card } from "@/components/ui/card";
|
||
import { api } from "@/lib/api";
|
||
|
||
interface MCPAddServerProps {
|
||
/**
|
||
* Callback when a server is successfully added
|
||
*/
|
||
onServerAdded: () => void;
|
||
/**
|
||
* Callback for error messages
|
||
*/
|
||
onError: (message: string) => void;
|
||
}
|
||
|
||
interface EnvironmentVariable {
|
||
id: string;
|
||
key: string;
|
||
value: string;
|
||
}
|
||
|
||
/**
|
||
* Component for adding new MCP servers
|
||
* Supports both stdio and SSE transport types
|
||
*/
|
||
export const MCPAddServer: React.FC<MCPAddServerProps> = ({
|
||
onServerAdded,
|
||
onError,
|
||
}) => {
|
||
const [transport, setTransport] = useState<"stdio" | "sse">("stdio");
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
// Stdio server state
|
||
const [stdioName, setStdioName] = useState("");
|
||
const [stdioCommand, setStdioCommand] = useState("");
|
||
const [stdioArgs, setStdioArgs] = useState("");
|
||
const [stdioScope, setStdioScope] = useState("local");
|
||
const [stdioEnvVars, setStdioEnvVars] = useState<EnvironmentVariable[]>([]);
|
||
|
||
// SSE server state
|
||
const [sseName, setSseName] = useState("");
|
||
const [sseUrl, setSseUrl] = useState("");
|
||
const [sseScope, setSseScope] = useState("local");
|
||
const [sseEnvVars, setSseEnvVars] = useState<EnvironmentVariable[]>([]);
|
||
|
||
/**
|
||
* Adds a new environment variable
|
||
*/
|
||
const addEnvVar = (type: "stdio" | "sse") => {
|
||
const newVar: EnvironmentVariable = {
|
||
id: `env-${Date.now()}`,
|
||
key: "",
|
||
value: "",
|
||
};
|
||
|
||
if (type === "stdio") {
|
||
setStdioEnvVars(prev => [...prev, newVar]);
|
||
} else {
|
||
setSseEnvVars(prev => [...prev, newVar]);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Updates an environment variable
|
||
*/
|
||
const updateEnvVar = (type: "stdio" | "sse", id: string, field: "key" | "value", value: string) => {
|
||
if (type === "stdio") {
|
||
setStdioEnvVars(prev => prev.map(v =>
|
||
v.id === id ? { ...v, [field]: value } : v
|
||
));
|
||
} else {
|
||
setSseEnvVars(prev => prev.map(v =>
|
||
v.id === id ? { ...v, [field]: value } : v
|
||
));
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Removes an environment variable
|
||
*/
|
||
const removeEnvVar = (type: "stdio" | "sse", id: string) => {
|
||
if (type === "stdio") {
|
||
setStdioEnvVars(prev => prev.filter(v => v.id !== id));
|
||
} else {
|
||
setSseEnvVars(prev => prev.filter(v => v.id !== id));
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Validates and adds a stdio server
|
||
*/
|
||
const handleAddStdioServer = async () => {
|
||
if (!stdioName.trim()) {
|
||
onError("服务器名称为必填项");
|
||
return;
|
||
}
|
||
|
||
if (!stdioCommand.trim()) {
|
||
onError("命令为必填项");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setSaving(true);
|
||
|
||
// Parse arguments
|
||
const args = stdioArgs.trim() ? stdioArgs.split(/\s+/) : [];
|
||
|
||
// Convert env vars to object
|
||
const env = stdioEnvVars.reduce((acc, { key, value }) => {
|
||
if (key.trim() && value.trim()) {
|
||
acc[key] = value;
|
||
}
|
||
return acc;
|
||
}, {} as Record<string, string>);
|
||
|
||
const result = await api.mcpAdd(
|
||
stdioName,
|
||
"stdio",
|
||
stdioCommand,
|
||
args,
|
||
env,
|
||
undefined,
|
||
stdioScope
|
||
);
|
||
|
||
if (result.success) {
|
||
// Reset form
|
||
setStdioName("");
|
||
setStdioCommand("");
|
||
setStdioArgs("");
|
||
setStdioEnvVars([]);
|
||
setStdioScope("local");
|
||
onServerAdded();
|
||
} else {
|
||
onError(result.message);
|
||
}
|
||
} catch (error) {
|
||
onError("添加服务器失败");
|
||
console.error("Failed to add stdio server:", error);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Validates and adds an SSE server
|
||
*/
|
||
const handleAddSseServer = async () => {
|
||
if (!sseName.trim()) {
|
||
onError("服务器名称为必填项");
|
||
return;
|
||
}
|
||
|
||
if (!sseUrl.trim()) {
|
||
onError("URL 为必填项");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setSaving(true);
|
||
|
||
// Convert env vars to object
|
||
const env = sseEnvVars.reduce((acc, { key, value }) => {
|
||
if (key.trim() && value.trim()) {
|
||
acc[key] = value;
|
||
}
|
||
return acc;
|
||
}, {} as Record<string, string>);
|
||
|
||
const result = await api.mcpAdd(
|
||
sseName,
|
||
"sse",
|
||
undefined,
|
||
[],
|
||
env,
|
||
sseUrl,
|
||
sseScope
|
||
);
|
||
|
||
if (result.success) {
|
||
// Reset form
|
||
setSseName("");
|
||
setSseUrl("");
|
||
setSseEnvVars([]);
|
||
setSseScope("local");
|
||
onServerAdded();
|
||
} else {
|
||
onError(result.message);
|
||
}
|
||
} catch (error) {
|
||
onError("添加服务器失败");
|
||
console.error("Failed to add SSE server:", error);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Renders environment variable inputs
|
||
*/
|
||
const renderEnvVars = (type: "stdio" | "sse", envVars: EnvironmentVariable[]) => {
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<Label className="text-sm font-medium">环境变量</Label>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => addEnvVar(type)}
|
||
className="gap-2"
|
||
>
|
||
<Plus className="h-3 w-3" />
|
||
添加变量
|
||
</Button>
|
||
</div>
|
||
|
||
{envVars.length > 0 && (
|
||
<div className="space-y-2">
|
||
{envVars.map((envVar) => (
|
||
<div key={envVar.id} className="flex items-center gap-2">
|
||
<Input
|
||
placeholder="KEY"
|
||
value={envVar.key}
|
||
onChange={(e) => updateEnvVar(type, envVar.id, "key", e.target.value)}
|
||
className="flex-1 font-mono text-sm"
|
||
/>
|
||
<span className="text-muted-foreground">=</span>
|
||
<Input
|
||
placeholder="value"
|
||
value={envVar.value}
|
||
onChange={(e) => updateEnvVar(type, envVar.id, "value", e.target.value)}
|
||
className="flex-1 font-mono text-sm"
|
||
/>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => removeEnvVar(type, envVar.id)}
|
||
className="h-8 w-8 hover:text-destructive"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="p-6 space-y-6">
|
||
<div>
|
||
<h3 className="text-base font-semibold">添加 MCP 服务器</h3>
|
||
<p className="text-sm text-muted-foreground mt-1">
|
||
配置新的 Model Context Protocol 服务器
|
||
</p>
|
||
</div>
|
||
|
||
<Tabs value={transport} onValueChange={(v) => setTransport(v as "stdio" | "sse")}>
|
||
<TabsList className="grid w-full grid-cols-2 max-w-sm mb-6">
|
||
<TabsTrigger value="stdio" className="gap-2">
|
||
<Terminal className="h-4 w-4 text-amber-500" />
|
||
Stdio
|
||
</TabsTrigger>
|
||
<TabsTrigger value="sse" className="gap-2">
|
||
<Globe className="h-4 w-4 text-emerald-500" />
|
||
SSE
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{/* Stdio Server */}
|
||
<TabsContent value="stdio" className="space-y-6">
|
||
<Card className="p-6 space-y-6">
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="stdio-name">服务器名称</Label>
|
||
<Input
|
||
id="stdio-name"
|
||
placeholder="my-server"
|
||
value={stdioName}
|
||
onChange={(e) => setStdioName(e.target.value)}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
用于标识此服务器的唯一名称
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="stdio-command">命令</Label>
|
||
<Input
|
||
id="stdio-command"
|
||
placeholder="/path/to/server"
|
||
value={stdioCommand}
|
||
onChange={(e) => setStdioCommand(e.target.value)}
|
||
className="font-mono"
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
执行服务器的命令
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="stdio-args">参数(可选)</Label>
|
||
<Input
|
||
id="stdio-args"
|
||
placeholder="arg1 arg2 arg3"
|
||
value={stdioArgs}
|
||
onChange={(e) => setStdioArgs(e.target.value)}
|
||
className="font-mono"
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
空格分隔的命令参数
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="stdio-scope">作用域</Label>
|
||
<SelectComponent
|
||
value={stdioScope}
|
||
onValueChange={(value: string) => setStdioScope(value)}
|
||
options={[
|
||
{ value: "local", label: "本地(仅限此项目)" },
|
||
{ value: "project", label: "项目(通过 .mcp.json 共享)" },
|
||
{ value: "user", label: "用户(所有项目)" },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
{renderEnvVars("stdio", stdioEnvVars)}
|
||
</div>
|
||
|
||
<div className="pt-2">
|
||
<Button
|
||
onClick={handleAddStdioServer}
|
||
disabled={saving}
|
||
className="w-full gap-2 bg-primary hover:bg-primary/90"
|
||
>
|
||
{saving ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
正在添加服务器...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Plus className="h-4 w-4" />
|
||
添加 Stdio 服务器
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
{/* SSE Server */}
|
||
<TabsContent value="sse" className="space-y-6">
|
||
<Card className="p-6 space-y-6">
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="sse-name">服务器名称</Label>
|
||
<Input
|
||
id="sse-name"
|
||
placeholder="sse-server"
|
||
value={sseName}
|
||
onChange={(e) => setSseName(e.target.value)}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
用于标识此服务器的唯一名称
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="sse-url">URL</Label>
|
||
<Input
|
||
id="sse-url"
|
||
placeholder="https://example.com/sse-endpoint"
|
||
value={sseUrl}
|
||
onChange={(e) => setSseUrl(e.target.value)}
|
||
className="font-mono"
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
SSE 端点 URL
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="sse-scope">作用域</Label>
|
||
<SelectComponent
|
||
value={sseScope}
|
||
onValueChange={(value: string) => setSseScope(value)}
|
||
options={[
|
||
{ value: "local", label: "本地(仅限此项目)" },
|
||
{ value: "project", label: "项目(通过 .mcp.json 共享)" },
|
||
{ value: "user", label: "用户(所有项目)" },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
{renderEnvVars("sse", sseEnvVars)}
|
||
</div>
|
||
|
||
<div className="pt-2">
|
||
<Button
|
||
onClick={handleAddSseServer}
|
||
disabled={saving}
|
||
className="w-full gap-2 bg-primary hover:bg-primary/90"
|
||
>
|
||
{saving ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
正在添加服务器...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Plus className="h-4 w-4" />
|
||
添加 SSE 服务器
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
{/* Example */}
|
||
<Card className="p-4 bg-muted/30">
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-2 text-sm font-medium">
|
||
<Info className="h-4 w-4 text-primary" />
|
||
<span>命令示例</span>
|
||
</div>
|
||
<div className="space-y-2 text-xs text-muted-foreground">
|
||
<div className="font-mono bg-background p-2 rounded">
|
||
<p>• Postgres: /path/to/postgres-mcp-server --connection-string "postgresql://..."</p>
|
||
<p>• Weather API: /usr/local/bin/weather-cli --api-key ABC123</p>
|
||
<p>• SSE Server: https://api.example.com/mcp/stream</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
);
|
||
};
|