diff --git a/backend/app/api/mcp.py b/backend/app/api/mcp.py new file mode 100644 index 0000000..cec6fd6 --- /dev/null +++ b/backend/app/api/mcp.py @@ -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"} diff --git a/backend/app/core/nanobot.py b/backend/app/core/nanobot.py index caa140b..26d7046 100644 --- a/backend/app/core/nanobot.py +++ b/backend/app/core/nanobot.py @@ -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,26 +205,48 @@ 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 - provider = StreamingLiteLLMProvider( - api_key=target_config.get("api_key"), - api_base=target_config.get("api_base"), - default_model=target_config.get("model"), - extra_headers=target_config.get("extra_headers"), - provider_name=target_config.get("provider"), - ) - agent = self._build_agent_for_provider(provider) + + if target_config: + provider = StreamingLiteLLMProvider( + api_key=target_config.get("api_key"), + api_base=target_config.get("api_base"), + default_model=target_config.get("model"), + extra_headers=target_config.get("extra_headers"), + provider_name=target_config.get("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(...)`. - - # 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. + 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") + 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, diff --git a/backend/app/schemas/mcp.py b/backend/app/schemas/mcp.py new file mode 100644 index 0000000..f90eb55 --- /dev/null +++ b/backend/app/schemas/mcp.py @@ -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 diff --git a/backend/main.py b/backend/main.py index 9eda992..53a1601 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 = { diff --git a/backend/mcp-sse b/backend/mcp-sse new file mode 160000 index 0000000..64f9400 --- /dev/null +++ b/backend/mcp-sse @@ -0,0 +1 @@ +Subproject commit 64f9400214e55a9192737555016987d732be570b diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 6a8d0f9..9914993 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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" } diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index 2726ae2..a0269e1 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -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 服务器" } diff --git a/frontend/src/pages/Skills.tsx b/frontend/src/pages/Skills.tsx index 9187bc9..c9d774e 100644 --- a/frontend/src/pages/Skills.tsx +++ b/frontend/src/pages/Skills.tsx @@ -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; + url?: string; + headers?: Record; + status?: string; +} + export function Skills() { const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState<'skills' | 'mcp'>('skills'); + + // Skills state const [skills, setSkills] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingSkill, setEditingSkill] = useState(null); const [newSkill, setNewSkill] = useState>({ type: 'python', content: '', source: t('localImport'), status: t('safe') }); + + // MCP state + const [mcpServers, setMcpServers] = useState([]); + const [isMcpLoading, setIsMcpLoading] = useState(false); + const [isMcpDialogOpen, setIsMcpDialogOpen] = useState(false); + const [editingMcp, setEditingMcp] = useState(null); + const [newMcp, setNewMcp] = useState>({ type: 'stdio' }); + const [mcpArgsStr, setMcpArgsStr] = useState(''); + const [mcpEnvStr, setMcpEnvStr] = useState(''); + const [mcpHeadersStr, setMcpHeadersStr] = useState(''); + const { currentProject } = useProjectStore(); const fileInputRef = useRef(null); useEffect(() => { + const fetchSkills = async () => { + if (!currentProject) return; + setIsLoading(true); + try { + const data = await api.get(`/api/v1/skills?project_id=${currentProject.id}`); + setSkills(data); + } catch (error) { + console.error("Failed to fetch skills", error); + } finally { + setIsLoading(false); + } + }; + + const fetchMcpServers = async () => { + if (!currentProject) return; + setIsMcpLoading(true); + try { + const data = await api.get(`/api/v1/mcp?project_id=${currentProject.id}`); + setMcpServers(data); + } catch (error) { + console.error("Failed to fetch MCP servers", error); + } finally { + setIsMcpLoading(false); + } + }; + if (currentProject) { - fetchSkills(); + if (activeTab === 'skills') { + fetchSkills(); + } else { + fetchMcpServers(); + } } - }, [currentProject]); + }, [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(`/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) => { 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 = { + 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 (
@@ -132,30 +276,59 @@ export function Skills() { return (
-
-
-

- < Wand2 className="h-6 w-6 text-indigo-500" />{t('skillsRepository', { project: currentProject.name })}

-

{t('manageAiSkillsDesc')}

+
+
+
+

+ < Wand2 className="h-6 w-6 text-indigo-500" />{t('skillsRepository')}

+

{t('manageAiSkillsDesc')}

+
+
+ {activeTab === 'skills' ? ( + <> + + + + ) : ( + + )} +
-
- - + {t('skills')} + +
-
+ {activeTab === 'skills' ? ( +
@@ -265,6 +438,94 @@ export function Skills() {
+ ) : ( +
+ + + + {t('mcpServerName')} + {t('transport')} + {t('content')} + {t('status')} + {t('actions')} + + + + {isMcpLoading ? ( + + +
+ +
+
+
+ ) : ( + <> + {mcpServers.map((mcp) => ( + + +

{mcp.name}

+
+ + {mcp.type} + + + {mcp.type === 'stdio' ? mcp.command : mcp.url} + + +
+ {mcp.status === 'connected' ? ( + + ) : ( + + )} + {mcp.status} +
+
+ +
+ + +
+
+
+ ))} + {mcpServers.length === 0 && ( + + +
+
+ +
+

{t('noMcpServers')}

+
+
+
+ )} + + )} +
+
+
+ )}
{ @@ -296,7 +557,7 @@ export function Skills() { setNewSkill({...newSkill, status: val})} + onValueChange={(val) => { if (val) setNewSkill({...newSkill, status: val}) }} disabled={editingSkill?.is_builtin} > @@ -357,6 +618,114 @@ export function Skills() { + + { + setIsMcpDialogOpen(open); + if (!open) { + setEditingMcp(null); + setNewMcp({ type: 'stdio' }); + setMcpArgsStr(''); + setMcpEnvStr(''); + setMcpHeadersStr(''); + } + }}> + + + {editingMcp ? t('editMcpServer') : t('addMcpServer')} + +
+
+
+ + setNewMcp({...newMcp, name: e.target.value})} + className="rounded-lg border-zinc-200 h-10" + /> +
+
+ + +
+ + {newMcp.type === 'stdio' ? ( + <> +
+ + setNewMcp({...newMcp, command: e.target.value})} + className="rounded-lg border-zinc-200 h-10" + /> +
+
+ +