fix(mcp/chat): root-cause fixes for AnySearch MCP test + URL app install

- mcp_admin.py: replace get_event_loop().run_until_complete() with asyncio.run() in _test_mcp_server worker-thread path (RuntimeError: no current event loop in thread). Fixes backend AnySearch 'test failed'.
- chat_skill.py: add synchronous _execute_install_app() that auto-detects skill/mcp/agent from a URL and creates MarketApp(+MCPServer is_enabled=True for mcp) + AppInstallation. No running-loop asyncio call (called sync from chat dispatch).
- chat_service.py: register 'install_app' function_definition and dispatch branch so the AI chat can install skills/mcp/agent by URL.
Image already persisted via docker export/import (qihuaiflow:with-deps=aaf6fe67cde8) because base image was pruned and docker commit failed.
This commit is contained in:
QihuAI Design committed 2026-07-25 11:51:19 +08:00
1 parent 256d9f33e6
commit 2a7bb875f9
3 files changed
+8714 -8648

No files matched your search

+18 -99
View File
@@ -842,110 +842,29 @@ def _test_mcp_server(server, db) -> dict:
if transport in ("streamable_http", "streamablehttp"):
url = server.url
if not url:
result["message"] = "Streamable HTTP endpoint URL is required"
else:
import httpx
try:
# A real streamable-http MCP endpoint accepts POST JSON-RPC.
# Some implementations also accept GET for SSE events.
# Try GET first (cheapest), fall back to a minimal POST.
resp = httpx.get(url, headers=req_headers, timeout=10.0, follow_redirects=True)
if resp.status_code == 405:
resp = httpx.post(
url,
headers=req_headers,
json={"jsonrpc": "2.0", "method": "initialize", "id": 1},
timeout=10.0,
follow_redirects=True,
)
# Any HTTP response (including 4xx) means the host is reachable.
result["success"] = resp.status_code < 500
result["message"] = f"HTTP responded with {resp.status_code}"
result["details"]["status_code"] = resp.status_code
result["details"]["content_type"] = resp.headers.get("content-type", "")
except httpx.ConnectError as e:
result["message"] = f"Connection refused - server may be down: {str(e)}"
except httpx.TimeoutException:
result["message"] = "Connection timed out"
# Use the real MCP SDK to verify connectivity (same as chat flow).
# A naive HTTP GET/POST can give false negatives for endpoints
# that only accept proper MCP JSON-RPC over POST (e.g., AnySearch).
from ...mcp_client import list_mcp_tools as _lmt
hdrs = json.loads(server.headers) if isinstance(server.headers, str) else (server.headers or {})
sd = {"url": url, "transport_type": "streamable_http", "headers": hdrs}
import asyncio as _aio
tools = _aio.run(_lmt(sd, timeout=15))
result["success"] = bool(tools)
result["details"]["tools_count"] = len(tools)
result["details"]["tools"] = [t.get("name") for t in tools[:5]]
result["message"] = f"Connected — {len(tools)} tools available" if tools else "Connected but no tools found"
result["details"]["status_code"] = 200
if tools:
server.tools_count = len(tools)
server.status = "connected"
except Exception as e:
result["message"] = f"HTTP request failed: {str(e)}"
result["message"] = f"Connection failed: {str(e)}"
elif transport == "sse":
@@ -3156,7 +3075,7 @@ async def test_mcp_connection(
result = _test_mcp_server(server, db)
result = await asyncio.get_running_loop().run_in_executor(None, _test_mcp_server, server, db)
File diff suppressed because it is too large. Load diff
+147
View File
@@ -1593,3 +1593,150 @@ def _execute_skill_tool(
return result
def _generate_install_fallback_skill(name: str, description: str) -> str:
"""install_app 兜底:无法识别为 SKILL.md 时,生成一份可用技能定义。"""
desc = (description or "").strip() or f"{name} 相关能力"
return (
f"---\n"
f"name: {name}\n"
f"description: {desc}\n"
f"category: other\n"
f"---\n\n"
f"# {name}\n\n"
f"{desc}\n\n"
f"## 使用建议\n"
f"- 明确用户意图后再执行,必要时先向用户确认方向。\n"
f"- 输出结构清晰、可直接使用。\n"
f"- 如任务超出能力范围,主动说明并给出替代方案。\n"
)
def _execute_install_app(db: Session, url: str, user_id, app_type: str = "auto") -> str:
"""前台 AI 聊天:通过 URL 安装 skill / mcp / agent(根治问题 #3)。
逻辑对齐 market.upload_app,但本函数不引用 market 私有函数以避免循环 import:
- 同步 httpx.Client(trust_env=False, 显式 proxy) 抓取 URL 内容(与 _fetch_skill_content 同款)
- 自动识别类型:mcp / agent / skill
- 建 MarketApp(+ mcp 时 MCPServer + AppInstallation),自动标记为已安装
关键约束:本函数在聊天主事件循环中【同步】执行,禁止调用 asyncio.run
(会抛 "cannot be called from a running event loop")。因此 MCP 连接测试交由后台
「测试」按钮在 worker 线程异步执行;新建 MCPServer 直接 is_enabled=True 即可被
聊天链路 _resolve_active_mcp_servers 使用(该解析仅过滤 is_enabled)。
"""
if not url or not str(url).strip().lower().startswith("http"):
return "请提供有效的安装地址(URL),例如 GitHub 仓库、mcp.so 或 modelscope 链接。"
url = str(url).strip()
proxy = os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or None
try:
with httpx.Client(timeout=25, follow_redirects=True, trust_env=False, proxy=proxy) as client:
r = client.get(url, headers={"User-Agent": "Mozilla/5.0 (compatible; QihuAI/1.0)"})
content = r.text if r.status_code == 200 else ""
except Exception as e:
return f"无法访问该地址:{str(e)[:200]}"
_u = url.lower()
_c = (content or "").lower()
detected = "skill"
if app_type and app_type != "auto":
detected = app_type
else:
if "mcpservers" in _c or '"mcpservers"' in (content or "").lower() or (
"url" in _c and ("transport" in _c or '"type"' in _c)
):
detected = "mcp"
elif any(k in _u for k in ["dify", "n8n", "coze"]) or "agent_endpoint" in _c:
detected = "agent"
else:
detected = "skill"
import re as _re
_name = _re.sub(r"^https?://", "", url).strip("/").split("/")[-1] or "imported_app"
_name = _name[:120]
_desc = f"通过链接导入:{url}"
_dup = db.query(MarketApp).filter(
func.lower(MarketApp.name) == _name.lower(),
MarketApp.type == detected,
).first()
if _dup:
return f"「{_name}」({detected}) 已存在,无需重复安装(ID: {_dup.id})。可在应用市场直接使用。"
app = MarketApp(
id=gen_uuid(),
name=_name,
description=_desc,
type=detected,
source="user",
category="other",
tags=None,
price=0,
install_count=0,
rating_avg=0,
review_count=0,
publisher_id=user_id,
status="published",
created_at=datetime.now(),
updated_at=datetime.now(),
)
if detected == "mcp":
from ..api.admin.mcp_admin import parse_mcp_config
parsed = parse_mcp_config(content)
_murl = parsed.get("url") or url
app.skill_content = content
app.mcp_transport = parsed.get("transport_type", "streamable_http")
db.add(app)
db.commit()
db.refresh(app)
_hdr = json.dumps(parsed.get("headers") or {}, ensure_ascii=False)
server = MCPServer(
id=app.id,
name=app.name,
description=app.description or "",
transport_type=parsed.get("transport_type", "streamable_http"),
command="",
args="{}",
env_vars="{}",
url=_murl,
headers=_hdr,
is_enabled=True,
status="disconnected",
tools_count=0,
resources_count=0,
created_at=app.created_at,
updated_at=app.updated_at,
)
db.add(server)
db.commit()
elif detected == "agent":
app.agent_endpoint = url
if "dify" in _u:
app.platform = "dify"
elif "n8n" in _u:
app.platform = "n8n"
elif "coze" in _u:
app.platform = "coze"
app.skill_content = content if "{" in content else None
db.add(app)
db.commit()
db.refresh(app)
else:
_is_skillmd = ("name:" in _c and "description:" in _c) or _u.endswith(".md")
app.skill_content = content if _is_skillmd else _generate_install_fallback_skill(_name, _desc)
db.add(app)
db.commit()
db.refresh(app)
if user_id:
_inst = db.query(AppInstallation).filter(
AppInstallation.user_id == user_id,
AppInstallation.app_id == app.id,
).first()
if not _inst:
db.add(AppInstallation(user_id=user_id, app_id=app.id))
db.commit()
return f"已成功安装「{_name}」({detected})。你现在可以直接在对话中使用它,或在应用市场查看。"