feat: add MCP

This commit is contained in:
qixinbo
2026-03-27 22:06:00 +08:00
parent 5d013231bc
commit b24aff956a
8 changed files with 600 additions and 69 deletions
+82
View File
@@ -0,0 +1,82 @@
import json
import uuid
from typing import List, Optional
from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from app.schemas.mcp import MCPServer, MCPServerCreate, MCPServerUpdate
from app.core.data_root import get_data_root
router = APIRouter()
def get_mcp_servers_file() -> Path:
return get_data_root() / "mcp_servers.json"
def read_mcp_servers() -> List[dict]:
file_path = get_mcp_servers_file()
if not file_path.exists():
return []
try:
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError:
return []
def write_mcp_servers(servers: List[dict]) -> None:
file_path = get_mcp_servers_file()
with open(file_path, "w", encoding="utf-8") as f:
json.dump(servers, f, indent=2, ensure_ascii=False)
@router.get("/mcp", response_model=List[MCPServer])
def list_mcp_servers(project_id: Optional[int] = None):
servers = read_mcp_servers()
if project_id is not None:
servers = [s for s in servers if s.get("project_id") == project_id]
return servers
@router.post("/mcp", response_model=MCPServer)
def create_mcp_server(server_in: MCPServerCreate):
servers = read_mcp_servers()
server_data = server_in.dict()
server_data["id"] = str(uuid.uuid4())
if "status" not in server_data or not server_data["status"]:
server_data["status"] = "disconnected"
servers.append(server_data)
write_mcp_servers(servers)
return server_data
@router.get("/mcp/{server_id}", response_model=MCPServer)
def get_mcp_server(server_id: str):
servers = read_mcp_servers()
for server in servers:
if server.get("id") == server_id:
return server
raise HTTPException(status_code=404, detail="MCP Server not found")
@router.put("/mcp/{server_id}", response_model=MCPServer)
def update_mcp_server(server_id: str, server_in: MCPServerUpdate):
servers = read_mcp_servers()
for i, server in enumerate(servers):
if server.get("id") == server_id:
update_data = server_in.dict(exclude_unset=True)
for key, value in update_data.items():
server[key] = value
servers[i] = server
write_mcp_servers(servers)
return server
raise HTTPException(status_code=404, detail="MCP Server not found")
@router.delete("/mcp/{server_id}")
def delete_mcp_server(server_id: str):
servers = read_mcp_servers()
filtered_servers = [s for s in servers if s.get("id") != server_id]
if len(servers) == len(filtered_servers):
raise HTTPException(status_code=404, detail="MCP Server not found")
write_mcp_servers(filtered_servers)
return {"status": "success"}
+47 -29
View File
@@ -44,7 +44,7 @@ class NanobotIntegration:
self.cron: CronService | None = None
self.config: Config | None = None
self._started = False
self._model_agent_cache: Dict[str, AgentLoop] = {}
self._model_agent_cache: Dict[tuple[str | None, int | None], AgentLoop] = {}
self._model_agent_lock = asyncio.Lock()
def initialize(self):
@@ -189,7 +189,7 @@ class NanobotIntegration:
self.cron.stop()
self._started = False
def _build_agent_for_provider(self, provider: Any) -> AgentLoop:
def _build_agent_for_provider(self, provider: Any, mcp_servers: dict | None = None) -> AgentLoop:
return AgentLoop(
bus=self.bus,
provider=provider,
@@ -205,16 +205,19 @@ class NanobotIntegration:
exec_config=self.config.tools.exec,
cron_service=self.cron,
restrict_to_workspace=self.config.tools.restrict_to_workspace,
session_manager=self.agent.sessions,
mcp_servers=self.config.tools.mcp_servers,
session_manager=self.agent.sessions if self.agent else None,
mcp_servers=mcp_servers if mcp_servers is not None else self.config.tools.mcp_servers,
channels_config=self.config.channels,
)
async def _get_or_create_model_agent(self, model_id: str, target_config: Dict[str, Any]) -> AgentLoop:
async def _get_or_create_model_agent(self, model_id: str | None, target_config: Dict[str, Any] | None, project_id: int | None = None) -> AgentLoop:
cache_key = (model_id, project_id)
async with self._model_agent_lock:
cached = self._model_agent_cache.get(model_id)
cached = self._model_agent_cache.get(cache_key)
if cached:
return cached
if target_config:
provider = StreamingLiteLLMProvider(
api_key=target_config.get("api_key"),
api_base=target_config.get("api_base"),
@@ -222,9 +225,28 @@ class NanobotIntegration:
extra_headers=target_config.get("extra_headers"),
provider_name=target_config.get("provider"),
)
agent = self._build_agent_for_provider(provider)
else:
provider = self._make_provider(self.config)
mcp_servers_dict = dict(self.config.tools.mcp_servers) if self.config.tools.mcp_servers else {}
if project_id is not None:
from app.api.mcp import list_mcp_servers
from nanobot.config.schema import MCPServerConfig
servers = list_mcp_servers(project_id=project_id)
for s in servers:
cfg = MCPServerConfig(
type=s.get("type"),
command=s.get("command") or "",
args=s.get("args") or [],
env=s.get("env") or {},
url=s.get("url") or "",
headers=s.get("headers") or {}
)
mcp_servers_dict[s["name"]] = cfg
agent = self._build_agent_for_provider(provider, mcp_servers=mcp_servers_dict)
self._register_custom_tools(agent)
self._model_agent_cache[model_id] = agent
self._model_agent_cache[cache_key] = agent
return agent
async def process_message(
@@ -233,6 +255,7 @@ class NanobotIntegration:
session_id: str = "api:default",
skill_ids: List[str] | None = None,
model_id: str | None = None,
project_id: int | None = None,
on_progress: Callable[[str], Awaitable[None]] | None = None,
):
if not self.agent:
@@ -240,32 +263,27 @@ class NanobotIntegration:
if not self._started:
await self.start()
# Handle dynamic model switching
# If model_id is provided, we need to fetch its config and create a temporary provider
# or update the current agent's provider context for this request.
# Since AgentLoop is stateful and tied to a provider, and we want to avoid recreating the whole agent for every request if possible,
# but changing the provider/model is a significant change.
#
# A simpler approach for this "stateless API" usage pattern:
# We can instantiate a lightweight version of the agent or provider just for this request if the model differs.
# OR, since we are using `process_direct`, we can check if `AgentLoop` supports overriding the model.
# Looking at `nanobot/agent/loop.py` (assumed), it uses `self.provider.completion(...)`.
if project_id is None:
from app.core.session_alias_store import session_alias_store
alias_info = session_alias_store.get_alias(session_id)
if alias_info and alias_info.get("project_id"):
project_id = alias_info.get("project_id")
# Strategy:
# 1. Load the model config from our JSON file using `model_id`.
# 2. Construct a temporary provider instance for this model.
# 3. Inject this provider into the agent for this request OR (cleaner) instantiate a temporary agent.
# Instantiating a whole AgentLoop might be heavy due to MCP/Cron etc.
# BUT `process_direct` is relatively isolated.
#
# Let's try to fetch the config first.
agent_to_use = self.agent
need_custom_agent = False
target_config = None
if model_id:
llm_configs = get_llm_configs()
target_config = next((item for item in llm_configs if item.get("id") == model_id), None)
if target_config:
if target_config.get("model") != self.agent.model:
agent_to_use = await self._get_or_create_model_agent(model_id, target_config)
if target_config and target_config.get("model") != self.agent.model:
need_custom_agent = True
if project_id is not None:
need_custom_agent = True
if need_custom_agent:
agent_to_use = await self._get_or_create_model_agent(model_id, target_config, project_id)
full_message = message
# We no longer inject the full skill content into the user's message here,
+30
View File
@@ -0,0 +1,30 @@
from typing import List, Dict, Optional, Literal
from pydantic import BaseModel, Field
class MCPServerBase(BaseModel):
name: str
type: Literal["stdio", "sse", "streamableHttp"]
command: Optional[str] = None
args: Optional[List[str]] = Field(default_factory=list)
env: Optional[Dict[str, str]] = Field(default_factory=dict)
url: Optional[str] = None
headers: Optional[Dict[str, str]] = Field(default_factory=dict)
project_id: int
status: str = "disconnected"
class MCPServerCreate(MCPServerBase):
pass
class MCPServerUpdate(BaseModel):
name: Optional[str] = None
type: Optional[Literal["stdio", "sse", "streamableHttp"]] = None
command: Optional[str] = None
args: Optional[List[str]] = None
env: Optional[Dict[str, str]] = None
url: Optional[str] = None
headers: Optional[Dict[str, str]] = None
project_id: Optional[int] = None
status: Optional[str] = None
class MCPServer(MCPServerBase):
id: str
+2 -1
View File
@@ -16,7 +16,7 @@ import re
import os
from datetime import datetime
from app.api import upload, llm, skills, users, datasources, projects, semantic
from app.api import upload, llm, skills, users, datasources, projects, semantic, mcp
from app.connectors.postgres import postgres_connector
from app.connectors.clickhouse import clickhouse_connector
from app.core.artifacts import extract_artifacts
@@ -59,6 +59,7 @@ app.include_router(users.router, prefix="/api/v1")
app.include_router(projects.router, prefix="/api/v1")
app.include_router(datasources.router, prefix="/api/v1")
app.include_router(semantic.router, prefix="/api/v1")
app.include_router(mcp.router, prefix="/api/v1")
STREAM_DELTA_CHUNK_SIZE = 48
PREVIEWABLE_TEXT_EXTENSIONS = {
+1
Submodule backend/mcp-sse added at 64f9400214
+18 -3
View File
@@ -181,8 +181,8 @@
"unknownError": "Unknown error",
"confirmDeleteSkill": "Are you sure you want to delete this skill?",
"selectProjectToManageSkills": "Please select a project at the top first to manage its skills",
"skillsRepository": "Skills Repository - {{project}}",
"manageAiSkillsDesc": "Manage AI skills and tools for this project, supports file uploads conforming to the agentskills.io standard",
"skillsRepository": "Skill Center",
"manageAiSkillsDesc": "Manage AI skills and MCP server configurations for this project",
"uploadSkill": "Upload Skill",
"source": "Source",
"installationTime": "Installation Time",
@@ -236,5 +236,20 @@
"dontHaveAccount": "Don't have an account?",
"alreadyHaveAccount": "Already have an account?",
"registrationSuccess": "Registration successful! Please login.",
"errorOccurred": "An error occurred"
"errorOccurred": "An error occurred",
"mcpConfig": "MCP Configuration",
"mcp": "MCP",
"skills": "Skills Configuration",
"transport": "Transport",
"command": "Command",
"args": "Args (JSON Array)",
"env": "Env (JSON Object)",
"url": "URL",
"headers": "Headers (JSON Object)",
"addMcpServer": "Add MCP Server",
"editMcpServer": "Edit MCP Server",
"mcpServerName": "MCP Server Name",
"noMcpServers": "No MCP servers configured",
"confirmDeleteMcpServer": "Are you sure you want to delete this MCP server?",
"saveMcpServer": "Save MCP Server"
}
+18 -3
View File
@@ -194,8 +194,8 @@
"unknownError": "未知错误",
"confirmDeleteSkill": "确定要删除这个技能吗?",
"selectProjectToManageSkills": "请先在顶部选择一个项目以管理其技能",
"skillsRepository": "Skills 仓库 - {{project}}",
"manageAiSkillsDesc": "管理该项目的 AI 技能和工具,支持符合 agentskills.io 标准的文件上传",
"skillsRepository": "技能中心",
"manageAiSkillsDesc": "管理该项目的 AI 技能和 MCP 服务器配置",
"uploadSkill": "上传 Skill",
"source": "来源",
"installationTime": "安装时间",
@@ -236,5 +236,20 @@
"dontHaveAccount": "还没有账号?",
"alreadyHaveAccount": "已经有账号了?",
"registrationSuccess": "注册成功!请登录。",
"errorOccurred": "发生了一个错误"
"errorOccurred": "发生了一个错误",
"mcpConfig": "MCP 配置",
"mcp": "MCP",
"skills": "Skills 配置",
"transport": "传输协议",
"command": "命令",
"args": "参数 (JSON 数组)",
"env": "环境变量 (JSON 对象)",
"url": "URL",
"headers": "请求头 (JSON 对象)",
"addMcpServer": "添加 MCP 服务器",
"editMcpServer": "编辑 MCP 服务器",
"mcpServerName": "MCP 服务器名称",
"noMcpServers": "暂无 MCP 服务器",
"confirmDeleteMcpServer": "确定要删除这个 MCP 服务器吗?",
"saveMcpServer": "保存 MCP 服务器"
}
+380 -11
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Trash2, Terminal, Loader2, FolderOpen, Eye, ShieldCheck, AlertCircle, Wand2, Upload } from "lucide-react";
import { Trash2, Terminal, Loader2, FolderOpen, Eye, ShieldCheck, AlertCircle, Wand2, Upload, Plus } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@@ -26,21 +26,78 @@ interface Skill {
is_builtin?: boolean;
}
interface MCPServer {
id?: string;
project_id?: number;
name: string;
type: 'stdio' | 'sse' | 'streamableHttp';
command?: string;
args?: string[];
env?: Record<string, string>;
url?: string;
headers?: Record<string, string>;
status?: string;
}
export function Skills() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'skills' | 'mcp'>('skills');
// Skills state
const [skills, setSkills] = useState<Skill[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
const [newSkill, setNewSkill] = useState<Partial<Skill>>({ type: 'python', content: '', source: t('localImport'), status: t('safe') });
// MCP state
const [mcpServers, setMcpServers] = useState<MCPServer[]>([]);
const [isMcpLoading, setIsMcpLoading] = useState(false);
const [isMcpDialogOpen, setIsMcpDialogOpen] = useState(false);
const [editingMcp, setEditingMcp] = useState<MCPServer | null>(null);
const [newMcp, setNewMcp] = useState<Partial<MCPServer>>({ type: 'stdio' });
const [mcpArgsStr, setMcpArgsStr] = useState('');
const [mcpEnvStr, setMcpEnvStr] = useState('');
const [mcpHeadersStr, setMcpHeadersStr] = useState('');
const { currentProject } = useProjectStore();
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (currentProject) {
fetchSkills();
const fetchSkills = async () => {
if (!currentProject) return;
setIsLoading(true);
try {
const data = await api.get<Skill[]>(`/api/v1/skills?project_id=${currentProject.id}`);
setSkills(data);
} catch (error) {
console.error("Failed to fetch skills", error);
} finally {
setIsLoading(false);
}
}, [currentProject]);
};
const fetchMcpServers = async () => {
if (!currentProject) return;
setIsMcpLoading(true);
try {
const data = await api.get<MCPServer[]>(`/api/v1/mcp?project_id=${currentProject.id}`);
setMcpServers(data);
} catch (error) {
console.error("Failed to fetch MCP servers", error);
} finally {
setIsMcpLoading(false);
}
};
if (currentProject) {
if (activeTab === 'skills') {
fetchSkills();
} else {
fetchMcpServers();
}
}
}, [currentProject, activeTab]);
const fetchSkills = async () => {
if (!currentProject) return;
@@ -55,6 +112,19 @@ export function Skills() {
}
};
const fetchMcpServers = async () => {
if (!currentProject) return;
setIsMcpLoading(true);
try {
const data = await api.get<MCPServer[]>(`/api/v1/mcp?project_id=${currentProject.id}`);
setMcpServers(data);
} catch (error) {
console.error("Failed to fetch MCP servers", error);
} finally {
setIsMcpLoading(false);
}
};
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !currentProject) return;
@@ -67,9 +137,10 @@ export function Skills() {
try {
await api.post('/api/v1/skills/upload', formData);
await fetchSkills();
} catch (error: any) {
} catch (error: unknown) {
console.error("Failed to upload skill", error);
const errorMessage = error.response?.data?.detail || error.message || t('unknownError');
const err = error as { response?: { data?: { detail?: string } }, message?: string };
const errorMessage = err.response?.data?.detail || err.message || t('unknownError');
alert(t('uploadFailed') + ': ' + errorMessage);
} finally {
setIsLoading(false);
@@ -121,6 +192,79 @@ export function Skills() {
}
};
const handleAddMcpServer = async () => {
if (!currentProject) return;
try {
const payload: Partial<MCPServer> = {
name: newMcp.name,
type: newMcp.type,
project_id: currentProject.id
};
if (newMcp.type === 'stdio') {
payload.command = newMcp.command;
try {
payload.args = mcpArgsStr ? JSON.parse(mcpArgsStr) : [];
} catch {
alert("Args must be a valid JSON array");
return;
}
try {
payload.env = mcpEnvStr ? JSON.parse(mcpEnvStr) : {};
} catch {
alert("Env must be a valid JSON object");
return;
}
} else {
payload.url = newMcp.url;
try {
payload.headers = mcpHeadersStr ? JSON.parse(mcpHeadersStr) : {};
} catch {
alert("Headers must be a valid JSON object");
return;
}
}
if (editingMcp && editingMcp.id) {
await api.put(`/api/v1/mcp/${editingMcp.id}?project_id=${currentProject.id}`, payload);
} else {
await api.post(`/api/v1/mcp`, payload);
}
await fetchMcpServers();
setIsMcpDialogOpen(false);
setEditingMcp(null);
setNewMcp({ type: 'stdio' });
setMcpArgsStr('');
setMcpEnvStr('');
setMcpHeadersStr('');
} catch (error: unknown) {
console.error("Failed to save MCP server", error);
const err = error as { response?: { data?: { detail?: string } }, message?: string };
alert(t('saveFailed') + (err.response?.data?.detail || err.message));
}
};
const handleEditMcpServer = (mcp: MCPServer) => {
setEditingMcp(mcp);
setNewMcp(mcp);
setMcpArgsStr(mcp.args ? JSON.stringify(mcp.args, null, 2) : '');
setMcpEnvStr(mcp.env ? JSON.stringify(mcp.env, null, 2) : '');
setMcpHeadersStr(mcp.headers ? JSON.stringify(mcp.headers, null, 2) : '');
setIsMcpDialogOpen(true);
};
const handleDeleteMcpServer = async (id: string) => {
if (!currentProject) return;
if (!window.confirm(t('confirmDeleteMcpServer'))) return;
try {
await api.delete(`/api/v1/mcp/${id}?project_id=${currentProject.id}`);
setMcpServers(mcpServers.filter(s => s.id !== id));
} catch (error) {
console.error("Failed to delete MCP server", error);
}
};
if (!currentProject) {
return (
<div className="h-full flex flex-col items-center justify-center text-zinc-500 gap-4">
@@ -132,13 +276,16 @@ export function Skills() {
return (
<div className="h-full flex flex-col bg-white overflow-hidden">
<div className="border-b border-zinc-100 px-8 py-5 flex items-center justify-between bg-white shrink-0">
<div className="border-b border-zinc-100 px-8 pt-5 bg-white shrink-0">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-zinc-900 flex items-center gap-2">
< Wand2 className="h-6 w-6 text-indigo-500" />{t('skillsRepository', { project: currentProject.name })}</h1>
< Wand2 className="h-6 w-6 text-indigo-500" />{t('skillsRepository')}</h1>
<p className="text-sm text-zinc-500 mt-1">{t('manageAiSkillsDesc')}</p>
</div>
<div className="flex gap-3">
{activeTab === 'skills' ? (
<>
<input
type="file"
ref={fileInputRef}
@@ -150,11 +297,37 @@ export function Skills() {
className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-4 w-4" />{t('uploadSkill')}</Button>
<Upload className="h-4 w-4" />{t('uploadSkill')}
</Button>
</>
) : (
<Button
className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2"
onClick={() => setIsMcpDialogOpen(true)}
>
<Plus className="h-4 w-4" />{t('addMcpServer')}
</Button>
)}
</div>
</div>
<div className="flex gap-6">
<button
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${activeTab === 'skills' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'}`}
onClick={() => setActiveTab('skills')}
>
{t('skills')}
</button>
<button
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${activeTab === 'mcp' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'}`}
onClick={() => setActiveTab('mcp')}
>
{t('mcpConfig')}
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-4 md:p-8 bg-zinc-50/30">
{activeTab === 'skills' ? (
<div className="bg-white rounded-xl border border-zinc-200 shadow-sm overflow-hidden min-w-[800px] lg:min-w-0">
<Table className="table-fixed w-full">
<TableHeader className="bg-zinc-50/50">
@@ -265,6 +438,94 @@ export function Skills() {
</TableBody>
</Table>
</div>
) : (
<div className="bg-white rounded-xl border border-zinc-200 shadow-sm overflow-hidden min-w-[800px] lg:min-w-0">
<Table className="table-fixed w-full">
<TableHeader className="bg-zinc-50/50">
<TableRow className="hover:bg-transparent">
<TableHead className="w-[25%] font-semibold text-zinc-700 py-3 px-4 text-sm">{t('mcpServerName')}</TableHead>
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm">{t('transport')}</TableHead>
<TableHead className="w-[30%] font-semibold text-zinc-700 py-3 px-4 text-sm">{t('content')}</TableHead>
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm">{t('status')}</TableHead>
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm text-right">{t('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isMcpLoading ? (
<TableRow>
<TableCell colSpan={5} className="py-24 text-center">
<div className="flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-indigo-500" />
</div>
</TableCell>
</TableRow>
) : (
<>
{mcpServers.map((mcp) => (
<TableRow key={mcp.id} className="group hover:bg-zinc-50/50 transition-colors border-zinc-100">
<TableCell className="py-4 px-4 overflow-hidden">
<h3 className="font-bold text-zinc-900 text-sm md:text-base truncate flex-1" title={mcp.name}>{mcp.name}</h3>
</TableCell>
<TableCell className="py-4 px-4 text-zinc-600 text-sm">
{mcp.type}
</TableCell>
<TableCell className="py-4 px-4 text-zinc-600 text-sm truncate" title={mcp.type === 'stdio' ? mcp.command : mcp.url}>
{mcp.type === 'stdio' ? mcp.command : mcp.url}
</TableCell>
<TableCell className="py-4 px-4 text-zinc-600 text-sm truncate">
<div className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] md:text-xs font-medium whitespace-nowrap ${
mcp.status === 'connected'
? 'bg-green-50 text-green-700 border border-green-100'
: 'bg-zinc-50 text-zinc-700 border border-zinc-100'
}`}>
{mcp.status === 'connected' ? (
<ShieldCheck className="h-3 w-3" />
) : (
<AlertCircle className="h-3 w-3" />
)}
{mcp.status}
</div>
</TableCell>
<TableCell className="py-4 px-4 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-zinc-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-md transition-all shrink-0"
onClick={() => handleEditMcpServer(mcp)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-zinc-400 hover:text-rose-600 hover:bg-rose-50 rounded-md transition-all shrink-0"
onClick={() => handleDeleteMcpServer(mcp.id!)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{mcpServers.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-24 text-center">
<div className="flex flex-col items-center gap-3 text-zinc-400">
<div className="p-4 bg-zinc-50 rounded-2xl">
<Terminal className="h-10 w-10 opacity-20" />
</div>
<p className="text-sm">{t('noMcpServers')}</p>
</div>
</TableCell>
</TableRow>
)}
</>
)}
</TableBody>
</Table>
</div>
)}
</div>
<Dialog open={isDialogOpen} onOpenChange={(open) => {
@@ -296,7 +557,7 @@ export function Skills() {
<Label htmlFor="type" className="text-zinc-600 font-medium text-sm">{t('type')}</Label>
<Select
value={newSkill.type}
onValueChange={(val: any) => setNewSkill({...newSkill, type: val})}
onValueChange={(val) => { if (val) setNewSkill({...newSkill, type: val}) }}
disabled={editingSkill?.is_builtin}
>
<SelectTrigger className="rounded-lg border-zinc-200 h-10">
@@ -313,7 +574,7 @@ export function Skills() {
<Label htmlFor="status" className="text-zinc-600 font-medium text-sm">{t('status')}</Label>
<Select
value={newSkill.status}
onValueChange={(val: any) => setNewSkill({...newSkill, status: val})}
onValueChange={(val) => { if (val) setNewSkill({...newSkill, status: val}) }}
disabled={editingSkill?.is_builtin}
>
<SelectTrigger className="rounded-lg border-zinc-200 h-10">
@@ -357,6 +618,114 @@ export function Skills() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isMcpDialogOpen} onOpenChange={(open) => {
setIsMcpDialogOpen(open);
if (!open) {
setEditingMcp(null);
setNewMcp({ type: 'stdio' });
setMcpArgsStr('');
setMcpEnvStr('');
setMcpHeadersStr('');
}
}}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col rounded-2xl p-0 overflow-hidden">
<DialogHeader className="p-6 pb-2">
<DialogTitle className="text-xl font-bold text-zinc-900">{editingMcp ? t('editMcpServer') : t('addMcpServer')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-2">
<div className="grid gap-5">
<div className="grid gap-1.5">
<Label htmlFor="mcp-name" className="text-zinc-600 font-medium text-sm">{t('name')}</Label>
<Input
id="mcp-name"
placeholder={t('mcpServerName')}
value={newMcp.name || ''}
onChange={(e) => setNewMcp({...newMcp, name: e.target.value})}
className="rounded-lg border-zinc-200 h-10"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="transport" className="text-zinc-600 font-medium text-sm">{t('transport')}</Label>
<Select
value={newMcp.type}
onValueChange={(val) => { if (val) setNewMcp({...newMcp, type: val as 'stdio' | 'sse' | 'streamableHttp'}) }}
>
<SelectTrigger className="rounded-lg border-zinc-200 h-10">
<SelectValue placeholder={t('transport')} />
</SelectTrigger>
<SelectContent className="rounded-lg">
<SelectItem value="stdio">stdio</SelectItem>
<SelectItem value="sse">sse</SelectItem>
<SelectItem value="streamableHttp">streamableHttp</SelectItem>
</SelectContent>
</Select>
</div>
{newMcp.type === 'stdio' ? (
<>
<div className="grid gap-1.5">
<Label htmlFor="command" className="text-zinc-600 font-medium text-sm">{t('command')}</Label>
<Input
id="command"
placeholder="e.g. npx, python"
value={newMcp.command || ''}
onChange={(e) => setNewMcp({...newMcp, command: e.target.value})}
className="rounded-lg border-zinc-200 h-10"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="args" className="text-zinc-600 font-medium text-sm">{t('args')}</Label>
<Textarea
id="args"
value={mcpArgsStr}
onChange={(e) => setMcpArgsStr(e.target.value)}
className="rounded-lg border-zinc-200 font-mono text-xs min-h-[80px] py-3 bg-zinc-50"
placeholder='e.g. ["-y", "@modelcontextprotocol/server-everything"]'
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="env" className="text-zinc-600 font-medium text-sm">{t('env')}</Label>
<Textarea
id="env"
value={mcpEnvStr}
onChange={(e) => setMcpEnvStr(e.target.value)}
className="rounded-lg border-zinc-200 font-mono text-xs min-h-[80px] py-3 bg-zinc-50"
placeholder='e.g. {"FOO": "bar"}'
/>
</div>
</>
) : (
<>
<div className="grid gap-1.5">
<Label htmlFor="url" className="text-zinc-600 font-medium text-sm">{t('url')}</Label>
<Input
id="url"
placeholder="e.g. http://localhost:8000/sse"
value={newMcp.url || ''}
onChange={(e) => setNewMcp({...newMcp, url: e.target.value})}
className="rounded-lg border-zinc-200 h-10"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="headers" className="text-zinc-600 font-medium text-sm">{t('headers')}</Label>
<Textarea
id="headers"
value={mcpHeadersStr}
onChange={(e) => setMcpHeadersStr(e.target.value)}
className="rounded-lg border-zinc-200 font-mono text-xs min-h-[80px] py-3 bg-zinc-50"
placeholder='e.g. {"Authorization": "Bearer token"}'
/>
</div>
</>
)}
</div>
</div>
<DialogFooter className="p-6 pt-2">
<Button onClick={handleAddMcpServer} className="bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg px-6 h-10 w-full">{t('saveMcpServer')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}