mirror of
https://github.com/OpenBMB/ChatDev.git
synced 2026-04-25 11:18:06 +00:00
remove replicated mcp server
This commit is contained in:
parent
55f565d53f
commit
e5587203ba
27
README-zh.md
27
README-zh.md
@ -170,6 +170,33 @@ make dev
|
||||
```
|
||||
检查所有 YAML 文件的语法与 schema 错误。
|
||||
|
||||
### 🦞 使用 OpenClaw 运行
|
||||
|
||||
OpenClaw 可以与 ChatDev 集成,通过 **调用已有的 agent 团队**,或在 ChatDev 中 **动态创建新的 agent 团队** 来完成任务。
|
||||
|
||||
开始使用:
|
||||
|
||||
1. 启动 ChatDev 2.0 后端。
|
||||
2. 为你的 OpenClaw 实例安装所需的技能:
|
||||
|
||||
```bash
|
||||
clawdbot install chatdev
|
||||
```
|
||||
|
||||
3. 让 OpenClaw 创建一个 ChatDev 工作流。例如:
|
||||
|
||||
* **自动化信息收集与内容发布**
|
||||
|
||||
```
|
||||
创建一个 ChatDev 工作流,用于自动收集热点信息,生成一篇小红书文案,并发布该内容
|
||||
```
|
||||
|
||||
* **多智能体地缘政治模拟**
|
||||
|
||||
```
|
||||
创建一个 ChatDev 工作流,构建多个 agent,用于模拟中东局势未来可能的发展
|
||||
```
|
||||
|
||||
|
||||
### 🐳 使用 Docker 运行
|
||||
你也可以通过 Docker Compose 运行整个应用。该方式可简化依赖管理,并提供一致的运行环境。
|
||||
|
||||
@ -582,6 +582,10 @@ const addLoadingEntry = (nodeId, baseKey, label) => {
|
||||
nodeState.entryMap.set(key, entry)
|
||||
nodeState.baseKeyToKey.set(baseKey, key)
|
||||
nodeState.message.loadingEntries.push(entry)
|
||||
runningLoadingEntries.value += 1
|
||||
if (runningLoadingEntries.value === 1) {
|
||||
startLoadingTimer()
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
@ -594,22 +598,37 @@ const finishLoadingEntry = (nodeId, baseKey) => {
|
||||
const entry = key ? nodeState.entryMap.get(key) : null
|
||||
if (!entry) return null
|
||||
|
||||
const wasRunning = entry.status === 'running'
|
||||
entry.status = 'done'
|
||||
entry.endedAt = Date.now()
|
||||
nodeState.baseKeyToKey.delete(baseKey)
|
||||
if (wasRunning) {
|
||||
runningLoadingEntries.value = Math.max(0, runningLoadingEntries.value - 1)
|
||||
if (runningLoadingEntries.value === 0) {
|
||||
stopLoadingTimer()
|
||||
}
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// Finish all running entries when a node ends or cancels
|
||||
const finalizeAllLoadingEntries = (nodeState, endedAt = Date.now()) => {
|
||||
if (!nodeState) return
|
||||
let finishedCount = 0
|
||||
for (const entry of nodeState.entryMap.values()) {
|
||||
if (entry.status === 'running') {
|
||||
entry.status = 'done'
|
||||
entry.endedAt = endedAt
|
||||
finishedCount += 1
|
||||
}
|
||||
}
|
||||
nodeState.baseKeyToKey.clear()
|
||||
if (finishedCount) {
|
||||
runningLoadingEntries.value = Math.max(0, runningLoadingEntries.value - finishedCount)
|
||||
if (runningLoadingEntries.value === 0) {
|
||||
stopLoadingTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global timer for updating loading bubble durations
|
||||
@ -876,10 +895,12 @@ const addDialogue = (name, message) => {
|
||||
|
||||
const isRight = name === "User"
|
||||
|
||||
const htmlContent = renderMarkdown(text)
|
||||
chatMessages.value.push({
|
||||
type: 'dialogue',
|
||||
name: name,
|
||||
text: text,
|
||||
htmlContent,
|
||||
avatar: avatar,
|
||||
isRight: isRight,
|
||||
timestamp: Date.now()
|
||||
@ -1497,13 +1518,6 @@ onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
loadWorkflows()
|
||||
|
||||
// Start the global timer
|
||||
if (!loadingTimerInterval) {
|
||||
loadingTimerInterval = setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@ -1513,10 +1527,8 @@ onUnmounted(() => {
|
||||
resetConnectionState()
|
||||
cleanupRecording()
|
||||
|
||||
if (loadingTimerInterval) {
|
||||
clearInterval(loadingTimerInterval)
|
||||
loadingTimerInterval = null
|
||||
}
|
||||
stopLoadingTimer()
|
||||
runningLoadingEntries.value = 0
|
||||
})
|
||||
|
||||
const { fromObject, fitView, onPaneReady, onNodesInitialized, setNodes, setEdges, edges } = useVueFlow()
|
||||
|
||||
@ -9,7 +9,6 @@ import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Mapping, Sequence
|
||||
|
||||
@ -36,19 +35,13 @@ class _FunctionManagerCacheEntry:
|
||||
auto_loaded: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class _McpToolCacheEntry:
|
||||
tools: List[Any]
|
||||
fetched_at: float
|
||||
|
||||
|
||||
class ToolManager:
|
||||
"""Manage function tools for agent nodes."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._functions_dir: Path = FUNCTION_CALLING_DIR
|
||||
self._function_managers: Dict[Path, _FunctionManagerCacheEntry] = {}
|
||||
self._mcp_tool_cache: Dict[str, _McpToolCacheEntry] = {}
|
||||
self._mcp_tool_cache: Dict[str, List[Any]] = {}
|
||||
self._mcp_stdio_clients: Dict[str, "_StdioClientWrapper"] = {}
|
||||
|
||||
def _get_function_manager(self) -> FunctionManager:
|
||||
@ -199,25 +192,19 @@ class ToolManager:
|
||||
|
||||
def _build_mcp_remote_specs(self, config: McpRemoteConfig) -> List[ToolSpec]:
|
||||
cache_key = f"remote:{config.cache_key()}"
|
||||
tools = self._get_mcp_tools(
|
||||
cache_key,
|
||||
config.cache_ttl,
|
||||
lambda: asyncio.run(
|
||||
tools = self._mcp_tool_cache.get(cache_key)
|
||||
if tools is None:
|
||||
tools = asyncio.run(
|
||||
self._fetch_mcp_tools_http(
|
||||
config.server,
|
||||
headers=config.headers,
|
||||
timeout=config.timeout,
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
self._mcp_tool_cache[cache_key] = tools
|
||||
|
||||
specs: List[ToolSpec] = []
|
||||
allowed_sources = {source for source in (config.tool_sources or []) if source}
|
||||
for tool in tools:
|
||||
meta = getattr(tool, "meta", None)
|
||||
source = meta.get("source") if isinstance(meta, Mapping) else None
|
||||
if allowed_sources and source not in allowed_sources:
|
||||
continue
|
||||
specs.append(
|
||||
ToolSpec(
|
||||
name=tool.name,
|
||||
@ -234,11 +221,10 @@ class ToolManager:
|
||||
raise ValueError("MCP local configuration missing launch key")
|
||||
|
||||
cache_key = f"stdio:{launch_key}"
|
||||
tools = self._get_mcp_tools(
|
||||
cache_key,
|
||||
config.cache_ttl,
|
||||
lambda: asyncio.run(self._fetch_mcp_tools_stdio(config, launch_key)),
|
||||
)
|
||||
tools = self._mcp_tool_cache.get(cache_key)
|
||||
if tools is None:
|
||||
tools = asyncio.run(self._fetch_mcp_tools_stdio(config, launch_key))
|
||||
self._mcp_tool_cache[cache_key] = tools
|
||||
|
||||
specs: List[ToolSpec] = []
|
||||
for tool in tools:
|
||||
@ -252,25 +238,28 @@ class ToolManager:
|
||||
)
|
||||
return specs
|
||||
|
||||
def _get_mcp_tools(
|
||||
def _execute_function_tool(
|
||||
self,
|
||||
cache_key: str,
|
||||
cache_ttl: float,
|
||||
fetcher,
|
||||
) -> List[Any]:
|
||||
entry = self._mcp_tool_cache.get(cache_key)
|
||||
if entry and self._is_cache_fresh(entry.fetched_at, cache_ttl):
|
||||
return entry.tools
|
||||
tools = fetcher()
|
||||
self._mcp_tool_cache[cache_key] = _McpToolCacheEntry(tools=tools, fetched_at=time.time())
|
||||
return tools
|
||||
|
||||
@staticmethod
|
||||
def _is_cache_fresh(fetched_at: float, cache_ttl: float) -> bool:
|
||||
if cache_ttl <= 0:
|
||||
return False
|
||||
return (time.time() - fetched_at) < cache_ttl
|
||||
tool_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
config: FunctionToolConfig,
|
||||
tool_context: Dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
mgr = self._get_function_manager()
|
||||
if config.auto_load:
|
||||
mgr.load_functions()
|
||||
func = mgr.get_function(tool_name)
|
||||
if func is None:
|
||||
raise ValueError(f"Tool {tool_name} not found in {self._functions_dir}")
|
||||
|
||||
call_args = dict(arguments or {})
|
||||
if (
|
||||
tool_context is not None
|
||||
# and "_context" not in call_args
|
||||
and self._function_accepts_context(func)
|
||||
):
|
||||
call_args["_context"] = tool_context
|
||||
return func(**call_args)
|
||||
|
||||
def _function_accepts_context(self, func: Any) -> bool:
|
||||
try:
|
||||
|
||||
@ -1,187 +0,0 @@
|
||||
import importlib.util
|
||||
import inspect
|
||||
import random
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.tools import FunctionTool
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
# Repo root (for loading built-in function tools).
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
_FUNCTION_CALLING_DIR = _REPO_ROOT / "functions" / "function_calling"
|
||||
|
||||
# MCP server for production use (supports hot-updated tools).
|
||||
mcp = FastMCP(
|
||||
"DevAll MCP Server",
|
||||
debug=True,
|
||||
)
|
||||
|
||||
_DYNAMIC_TOOLS_DIR = Path(__file__).parent / "dynamic_tools"
|
||||
_DYNAMIC_TOOLS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _safe_tool_filename(filename: str) -> str:
|
||||
name = Path(filename).name
|
||||
if not name.endswith(".py"):
|
||||
raise ValueError("filename must end with .py")
|
||||
return name
|
||||
|
||||
|
||||
def _load_module_from_path(path: Path) -> Any:
|
||||
module_name = f"_dynamic_mcp_{path.stem}_{uuid.uuid4().hex}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ValueError("failed to create module spec")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _select_functions(module: Any, allowlist: Iterable[str] | None) -> list[Any]:
|
||||
allowset = {name.strip() for name in allowlist} if allowlist else None
|
||||
selected = []
|
||||
for name, obj in inspect.getmembers(module, inspect.isfunction):
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
if getattr(obj, "__module__", None) != module.__name__:
|
||||
continue
|
||||
if allowset is not None and name not in allowset:
|
||||
continue
|
||||
selected.append(obj)
|
||||
if allowset:
|
||||
missing = sorted(allowset - {fn.__name__ for fn in selected})
|
||||
if missing:
|
||||
raise ValueError(f"functions not found in module: {', '.join(missing)}")
|
||||
return selected
|
||||
|
||||
|
||||
def _register_functions(functions: Iterable[Any], *, replace: bool, source: str) -> list[str]:
|
||||
added: list[str] = []
|
||||
for fn in functions:
|
||||
tool = FunctionTool.from_function(fn, meta={"source": source})
|
||||
if replace:
|
||||
try:
|
||||
mcp.remove_tool(tool.name)
|
||||
except Exception:
|
||||
pass
|
||||
mcp.add_tool(tool)
|
||||
added.append(tool.name)
|
||||
return added
|
||||
|
||||
|
||||
def register_tools_from_payload(payload: dict) -> dict:
|
||||
filename = payload.get("filename")
|
||||
content = payload.get("content")
|
||||
function_names = payload.get("functions")
|
||||
replace = payload.get("replace", True)
|
||||
|
||||
if not isinstance(filename, str) or not filename.strip():
|
||||
raise ValueError("filename is required")
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
raise ValueError("content is required")
|
||||
if function_names is not None and not isinstance(function_names, list):
|
||||
raise ValueError("functions must be a list of strings")
|
||||
|
||||
safe_name = _safe_tool_filename(filename.strip())
|
||||
|
||||
file_path = _DYNAMIC_TOOLS_DIR / safe_name
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
|
||||
module = _load_module_from_path(file_path)
|
||||
functions = _select_functions(module, function_names)
|
||||
if not functions:
|
||||
raise ValueError("no functions found to register")
|
||||
|
||||
added = _register_functions(functions, replace=bool(replace), source="dynamic_tools")
|
||||
|
||||
return {"status": "ok", "added": added, "file": str(file_path)}
|
||||
|
||||
|
||||
@mcp.custom_route("/admin/tools/upload", methods=["POST"])
|
||||
async def upload_tool(request: Request) -> JSONResponse:
|
||||
try:
|
||||
payload = await request.json()
|
||||
result = register_tools_from_payload(payload)
|
||||
except ValueError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
except Exception as exc:
|
||||
return JSONResponse({"error": f"failed to load tools: {exc}"}, status_code=400)
|
||||
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@mcp.custom_route("/admin/tools/reload", methods=["POST"])
|
||||
async def reload_tools(request: Request) -> JSONResponse:
|
||||
replace = True
|
||||
try:
|
||||
payload = await request.json()
|
||||
if isinstance(payload, dict) and "replace" in payload:
|
||||
replace = bool(payload["replace"])
|
||||
except Exception:
|
||||
replace = True
|
||||
result = load_tools_from_directory(replace=replace)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@mcp.custom_route("/admin/tools/list", methods=["GET"])
|
||||
async def list_tools(request: Request) -> JSONResponse:
|
||||
tools = await mcp.get_tools()
|
||||
items = []
|
||||
for key in sorted(tools.keys()):
|
||||
tool = tools[key]
|
||||
meta = tool.meta or {}
|
||||
source = meta.get("source", "unknown")
|
||||
call_methods = ["mcp_remote"]
|
||||
if source == "function_calling":
|
||||
call_methods.append("function")
|
||||
payload = {
|
||||
"key": key,
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.parameters,
|
||||
"enabled": tool.enabled,
|
||||
"source": source,
|
||||
"call_methods": call_methods,
|
||||
}
|
||||
if meta:
|
||||
payload["meta"] = meta
|
||||
if tool.output_schema is not None:
|
||||
payload["output_schema"] = tool.output_schema
|
||||
items.append(payload)
|
||||
return JSONResponse({"tools": items})
|
||||
|
||||
|
||||
def load_tools_from_directory(*, replace: bool = True) -> dict:
|
||||
added: list[str] = []
|
||||
errors: list[str] = []
|
||||
for directory in (_FUNCTION_CALLING_DIR, _DYNAMIC_TOOLS_DIR):
|
||||
if not directory.exists():
|
||||
continue
|
||||
source = "local_tools" if directory == _FUNCTION_CALLING_DIR else "mcp_tools"
|
||||
for path in sorted(directory.glob("*.py")):
|
||||
if path.name.startswith("_") or path.name == "__init__.py":
|
||||
continue
|
||||
try:
|
||||
module = _load_module_from_path(path)
|
||||
functions = _select_functions(module, None)
|
||||
if not functions:
|
||||
continue
|
||||
added.extend(_register_functions(functions, replace=replace, source=source))
|
||||
except Exception as exc:
|
||||
errors.append(f"{path.name}: {exc}")
|
||||
return {"added": added, "errors": errors}
|
||||
|
||||
|
||||
_bootstrap = load_tools_from_directory(replace=True)
|
||||
if _bootstrap["errors"]:
|
||||
print(f"Dynamic tool load errors: {_bootstrap['errors']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Starting MCP server...")
|
||||
print("Run standalone with: fastmcp run server/mcp_server.py --transport streamable-http --port 8010")
|
||||
mcp.run()
|
||||
Loading…
x
Reference in New Issue
Block a user