- 新增 mcp_server.py: 标准 MCP stdio 传输层入口 - TUI: 回车发送消息,Shift+回车换行 (SendTextArea子类) - 子Agent: 移除 max_steps=20 限制 - 安全引擎: 移除 ALWAYS_CONFIRM 关键词强制拦截 - code_editor: diff弹窗绿底(+)/红底(-)背景色
148 lines
4.7 KiB
Python
148 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
小狸 MCP Stdio Server
|
|
=====================
|
|
标准 MCP (Model Context Protocol) stdio 传输层入口。
|
|
通过 stdin/stdout 以 JSON-RPC 2.0 格式通信,
|
|
可被 Claude Desktop、Cursor、OpenClaw 等标准 MCP 客户端直接连接。
|
|
|
|
用法:
|
|
python mcp_server.py [--plugins-dir plugins] [--skills-dir skills]
|
|
|
|
Claude Desktop 配置示例:
|
|
{
|
|
"mcpServers": {
|
|
"xiaoli": {
|
|
"command": "python",
|
|
"args": ["/path/to/xiaoli-cli/mcp_server.py"]
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import json
|
|
import argparse
|
|
import logging
|
|
|
|
# 确保当前目录在 sys.path 中,以便导入项目模块
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
if SCRIPT_DIR not in sys.path:
|
|
sys.path.insert(0, SCRIPT_DIR)
|
|
|
|
# 日志输出到 stderr,不干扰 stdout 上的 JSON-RPC 通信
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="[xiaoli-mcp] %(message)s",
|
|
stream=sys.stderr,
|
|
)
|
|
logger = logging.getLogger("xiaoli-mcp")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="小狸 MCP Stdio Server")
|
|
parser.add_argument("--plugins-dir", default="plugins", help="插件目录 (默认: plugins)")
|
|
parser.add_argument("--skills-dir", default="skills", help="技能目录 (默认: skills)")
|
|
args = parser.parse_args()
|
|
|
|
# 切换到脚本所在目录,保证相对路径正确
|
|
os.chdir(SCRIPT_DIR)
|
|
|
|
# 初始化 UnifiedToolManager
|
|
# 将 initialize() 中的 print 重定向到 stderr,避免污染 stdout JSON-RPC 通道
|
|
import io
|
|
from contextlib import redirect_stdout
|
|
|
|
from unified_tool_manager import UnifiedToolManager
|
|
|
|
manager = UnifiedToolManager()
|
|
captured_output = io.StringIO()
|
|
with redirect_stdout(captured_output):
|
|
manager.initialize(
|
|
plugins_dir=args.plugins_dir,
|
|
skills_dir=args.skills_dir,
|
|
)
|
|
|
|
# 将初始化时的 print 输出转发到 stderr
|
|
init_output = captured_output.getvalue()
|
|
if init_output:
|
|
for line in init_output.strip().split('\n'):
|
|
logger.info(line)
|
|
|
|
tool_count = len(manager._tools)
|
|
logger.info(f"MCP Server 已启动,共加载 {tool_count} 个工具")
|
|
logger.info("等待 stdin JSON-RPC 请求...")
|
|
|
|
# 主循环:从 stdin 逐行读取 JSON-RPC 请求
|
|
try:
|
|
for line in sys.stdin:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
try:
|
|
request = json.loads(line)
|
|
except json.JSONDecodeError as e:
|
|
# 无法解析的 JSON,返回解析错误
|
|
error_resp = {
|
|
"jsonrpc": "2.0",
|
|
"id": None,
|
|
"error": {"code": -32700, "message": f"Parse error: {e}"},
|
|
}
|
|
_send(error_resp)
|
|
continue
|
|
|
|
# 处理通知(没有 id 字段的请求)— MCP 规范中通知不需要响应
|
|
req_id = request.get("id")
|
|
method = request.get("method", "")
|
|
|
|
# 特殊处理 notifications/initialized — 客户端初始化完成通知
|
|
if method == "notifications/initialized":
|
|
logger.info("客户端已发送 initialized 通知")
|
|
continue
|
|
|
|
# 特殊处理 $/cancelRequest
|
|
if method == "$/cancelRequest":
|
|
logger.info(f"收到取消请求: {request.get('params', {}).get('id')}")
|
|
continue
|
|
|
|
# 委托给 UnifiedToolManager 处理
|
|
# 拦截插件可能产生的 stdout 输出,转发到 stderr
|
|
captured = io.StringIO()
|
|
try:
|
|
with redirect_stdout(captured):
|
|
response = manager.handle_mcp_request(request)
|
|
plugin_output = captured.getvalue()
|
|
if plugin_output:
|
|
for p_line in plugin_output.strip().split('\n'):
|
|
logger.info(f"[plugin] {p_line}")
|
|
except Exception as e:
|
|
logger.error(f"处理请求异常: {e}")
|
|
response = {
|
|
"jsonrpc": "2.0",
|
|
"id": req_id,
|
|
"error": {"code": -32603, "message": f"Internal error: {e}"},
|
|
}
|
|
|
|
_send(response)
|
|
|
|
except KeyboardInterrupt:
|
|
logger.info("收到中断信号,正在关闭...")
|
|
except EOFError:
|
|
logger.info("stdin 已关闭,正在退出...")
|
|
finally:
|
|
manager.shutdown()
|
|
logger.info("MCP Server 已关闭")
|
|
|
|
|
|
def _send(response: dict):
|
|
"""将 JSON-RPC 响应写入 stdout 并刷新"""
|
|
text = json.dumps(response, ensure_ascii=False)
|
|
sys.stdout.write(text + "\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|