mirror of
https://github.com/OpenBMB/ChatDev.git
synced 2026-08-01 10:56: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 错误。
|
检查所有 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 运行
|
||||||
你也可以通过 Docker Compose 运行整个应用。该方式可简化依赖管理,并提供一致的运行环境。
|
你也可以通过 Docker Compose 运行整个应用。该方式可简化依赖管理,并提供一致的运行环境。
|
||||||
|
|||||||
@ -582,6 +582,10 @@ const addLoadingEntry = (nodeId, baseKey, label) => {
|
|||||||
nodeState.entryMap.set(key, entry)
|
nodeState.entryMap.set(key, entry)
|
||||||
nodeState.baseKeyToKey.set(baseKey, key)
|
nodeState.baseKeyToKey.set(baseKey, key)
|
||||||
nodeState.message.loadingEntries.push(entry)
|
nodeState.message.loadingEntries.push(entry)
|
||||||
|
runningLoadingEntries.value += 1
|
||||||
|
if (runningLoadingEntries.value === 1) {
|
||||||
|
startLoadingTimer()
|
||||||
|
}
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -594,22 +598,37 @@ const finishLoadingEntry = (nodeId, baseKey) => {
|
|||||||
const entry = key ? nodeState.entryMap.get(key) : null
|
const entry = key ? nodeState.entryMap.get(key) : null
|
||||||
if (!entry) return null
|
if (!entry) return null
|
||||||
|
|
||||||
|
const wasRunning = entry.status === 'running'
|
||||||
entry.status = 'done'
|
entry.status = 'done'
|
||||||
entry.endedAt = Date.now()
|
entry.endedAt = Date.now()
|
||||||
nodeState.baseKeyToKey.delete(baseKey)
|
nodeState.baseKeyToKey.delete(baseKey)
|
||||||
|
if (wasRunning) {
|
||||||
|
runningLoadingEntries.value = Math.max(0, runningLoadingEntries.value - 1)
|
||||||
|
if (runningLoadingEntries.value === 0) {
|
||||||
|
stopLoadingTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finish all running entries when a node ends or cancels
|
// Finish all running entries when a node ends or cancels
|
||||||
const finalizeAllLoadingEntries = (nodeState, endedAt = Date.now()) => {
|
const finalizeAllLoadingEntries = (nodeState, endedAt = Date.now()) => {
|
||||||
if (!nodeState) return
|
if (!nodeState) return
|
||||||
|
let finishedCount = 0
|
||||||
for (const entry of nodeState.entryMap.values()) {
|
for (const entry of nodeState.entryMap.values()) {
|
||||||
if (entry.status === 'running') {
|
if (entry.status === 'running') {
|
||||||
entry.status = 'done'
|
entry.status = 'done'
|
||||||
entry.endedAt = endedAt
|
entry.endedAt = endedAt
|
||||||
|
finishedCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
nodeState.baseKeyToKey.clear()
|
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
|
// Global timer for updating loading bubble durations
|
||||||
@ -876,10 +895,12 @@ const addDialogue = (name, message) => {
|
|||||||
|
|
||||||
const isRight = name === "User"
|
const isRight = name === "User"
|
||||||
|
|
||||||
|
const htmlContent = renderMarkdown(text)
|
||||||
chatMessages.value.push({
|
chatMessages.value.push({
|
||||||
type: 'dialogue',
|
type: 'dialogue',
|
||||||
name: name,
|
name: name,
|
||||||
text: text,
|
text: text,
|
||||||
|
htmlContent,
|
||||||
avatar: avatar,
|
avatar: avatar,
|
||||||
isRight: isRight,
|
isRight: isRight,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
@ -1497,13 +1518,6 @@ onMounted(() => {
|
|||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
document.addEventListener('keydown', handleKeydown)
|
document.addEventListener('keydown', handleKeydown)
|
||||||
loadWorkflows()
|
loadWorkflows()
|
||||||
|
|
||||||
// Start the global timer
|
|
||||||
if (!loadingTimerInterval) {
|
|
||||||
loadingTimerInterval = setInterval(() => {
|
|
||||||
now.value = Date.now()
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@ -1513,10 +1527,8 @@ onUnmounted(() => {
|
|||||||
resetConnectionState()
|
resetConnectionState()
|
||||||
cleanupRecording()
|
cleanupRecording()
|
||||||
|
|
||||||
if (loadingTimerInterval) {
|
stopLoadingTimer()
|
||||||
clearInterval(loadingTimerInterval)
|
runningLoadingEntries.value = 0
|
||||||
loadingTimerInterval = null
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const { fromObject, fitView, onPaneReady, onNodesInitialized, setNodes, setEdges, edges } = useVueFlow()
|
const { fromObject, fitView, onPaneReady, onNodesInitialized, setNodes, setEdges, edges } = useVueFlow()
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import logging
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Mapping, Sequence
|
from typing import Any, Dict, List, Mapping, Sequence
|
||||||
|
|
||||||
@ -36,19 +35,13 @@ class _FunctionManagerCacheEntry:
|
|||||||
auto_loaded: bool = False
|
auto_loaded: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _McpToolCacheEntry:
|
|
||||||
tools: List[Any]
|
|
||||||
fetched_at: float
|
|
||||||
|
|
||||||
|
|
||||||
class ToolManager:
|
class ToolManager:
|
||||||
"""Manage function tools for agent nodes."""
|
"""Manage function tools for agent nodes."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._functions_dir: Path = FUNCTION_CALLING_DIR
|
self._functions_dir: Path = FUNCTION_CALLING_DIR
|
||||||
self._function_managers: Dict[Path, _FunctionManagerCacheEntry] = {}
|
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"] = {}
|
self._mcp_stdio_clients: Dict[str, "_StdioClientWrapper"] = {}
|
||||||
|
|
||||||
def _get_function_manager(self) -> FunctionManager:
|
def _get_function_manager(self) -> FunctionManager:
|
||||||
@ -199,25 +192,19 @@ class ToolManager:
|
|||||||
|
|
||||||
def _build_mcp_remote_specs(self, config: McpRemoteConfig) -> List[ToolSpec]:
|
def _build_mcp_remote_specs(self, config: McpRemoteConfig) -> List[ToolSpec]:
|
||||||
cache_key = f"remote:{config.cache_key()}"
|
cache_key = f"remote:{config.cache_key()}"
|
||||||
tools = self._get_mcp_tools(
|
tools = self._mcp_tool_cache.get(cache_key)
|
||||||
cache_key,
|
if tools is None:
|
||||||
config.cache_ttl,
|
tools = asyncio.run(
|
||||||
lambda: asyncio.run(
|
|
||||||
self._fetch_mcp_tools_http(
|
self._fetch_mcp_tools_http(
|
||||||
config.server,
|
config.server,
|
||||||
headers=config.headers,
|
headers=config.headers,
|
||||||
timeout=config.timeout,
|
timeout=config.timeout,
|
||||||
)
|
)
|
||||||
),
|
)
|
||||||
)
|
self._mcp_tool_cache[cache_key] = tools
|
||||||
|
|
||||||
specs: List[ToolSpec] = []
|
specs: List[ToolSpec] = []
|
||||||
allowed_sources = {source for source in (config.tool_sources or []) if source}
|
|
||||||
for tool in tools:
|
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(
|
specs.append(
|
||||||
ToolSpec(
|
ToolSpec(
|
||||||
name=tool.name,
|
name=tool.name,
|
||||||
@ -234,11 +221,10 @@ class ToolManager:
|
|||||||
raise ValueError("MCP local configuration missing launch key")
|
raise ValueError("MCP local configuration missing launch key")
|
||||||
|
|
||||||
cache_key = f"stdio:{launch_key}"
|
cache_key = f"stdio:{launch_key}"
|
||||||
tools = self._get_mcp_tools(
|
tools = self._mcp_tool_cache.get(cache_key)
|
||||||
cache_key,
|
if tools is None:
|
||||||
config.cache_ttl,
|
tools = asyncio.run(self._fetch_mcp_tools_stdio(config, launch_key))
|
||||||
lambda: asyncio.run(self._fetch_mcp_tools_stdio(config, launch_key)),
|
self._mcp_tool_cache[cache_key] = tools
|
||||||
)
|
|
||||||
|
|
||||||
specs: List[ToolSpec] = []
|
specs: List[ToolSpec] = []
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
@ -252,25 +238,28 @@ class ToolManager:
|
|||||||
)
|
)
|
||||||
return specs
|
return specs
|
||||||
|
|
||||||
def _get_mcp_tools(
|
def _execute_function_tool(
|
||||||
self,
|
self,
|
||||||
cache_key: str,
|
tool_name: str,
|
||||||
cache_ttl: float,
|
arguments: Dict[str, Any],
|
||||||
fetcher,
|
config: FunctionToolConfig,
|
||||||
) -> List[Any]:
|
tool_context: Dict[str, Any] | None = None,
|
||||||
entry = self._mcp_tool_cache.get(cache_key)
|
) -> Any:
|
||||||
if entry and self._is_cache_fresh(entry.fetched_at, cache_ttl):
|
mgr = self._get_function_manager()
|
||||||
return entry.tools
|
if config.auto_load:
|
||||||
tools = fetcher()
|
mgr.load_functions()
|
||||||
self._mcp_tool_cache[cache_key] = _McpToolCacheEntry(tools=tools, fetched_at=time.time())
|
func = mgr.get_function(tool_name)
|
||||||
return tools
|
if func is None:
|
||||||
|
raise ValueError(f"Tool {tool_name} not found in {self._functions_dir}")
|
||||||
@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
|
|
||||||
|
|
||||||
|
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:
|
def _function_accepts_context(self, func: Any) -> bool:
|
||||||
try:
|
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