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:
1 parent
256d9f33e6
commit
2a7bb875f9
3 files changed
+196
-130
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ from app.api.chat_media import (_generate_media_task, detect_ambiguous_creative_
|
||||
|
||||
|
||||
|
||||
from app.api.chat_skill import (_discover_installed_skills, _execute_skill_tool, _resolve_market_agents, _resolve_market_skills)
|
||||
from app.api.chat_skill import (_discover_installed_skills, _execute_skill_tool, _resolve_market_agents, _resolve_market_skills, _execute_install_app)
|
||||
|
||||
|
||||
|
||||
@@ -1999,15 +1999,6 @@ async def _send_message_core(
|
||||
|
||||
process_steps = [] # 过程步骤(思考/技能/MCP执行),用于前端收拢式展示
|
||||
|
||||
async def _push_step(step: dict):
|
||||
"""追加过程步骤,并在流式场景实时推送给前端(step 事件)。"""
|
||||
await _push_step(step)
|
||||
if on_event is not None:
|
||||
try:
|
||||
await on_event({"type": "step", "step": step, "index": len(process_steps) - 1})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4360,6 +4351,20 @@ async def _send_message_core(
|
||||
|
||||
|
||||
|
||||
if isinstance(function_definitions, list):
|
||||
function_definitions.append({
|
||||
"name": "install_app",
|
||||
"description": "当用户提供技能/Skill、MCP 服务或 Agent 的链接(如 GitHub 仓库、mcp.so、modelscope 等)并希望安装到平台时使用。自动识别类型(skill/mcp/agent)并安装,随后可在对话中直接使用。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "要安装的技能/MCP/Agent 地址(URL)"},
|
||||
"type": {"type": "string", "enum": ["auto", "skill", "mcp", "agent"], "description": "安装类型,auto 表示自动识别(默认)"},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
})
|
||||
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
|
||||
|
||||
@@ -4498,7 +4503,7 @@ async def _send_message_core(
|
||||
|
||||
})
|
||||
|
||||
await _push_step({
|
||||
process_steps.append({
|
||||
|
||||
"type": "skill",
|
||||
|
||||
@@ -4800,11 +4805,10 @@ async def _send_message_core(
|
||||
|
||||
try:
|
||||
|
||||
if stream and on_event is not None:
|
||||
if stream and on_event is not None and round_num == 0 and not (effective_mcp_ids or effective_skill_ids or effective_agent_ids):
|
||||
|
||||
_sc = ""
|
||||
_sr = ""
|
||||
_final_msg = None
|
||||
async for _kind, _text in await achat_complete(
|
||||
api_url, api_key, request_body,
|
||||
proxy=_proxy_for_url(api_url),
|
||||
@@ -4817,14 +4821,6 @@ async def _send_message_core(
|
||||
elif _kind == "reasoning":
|
||||
_sr += _text
|
||||
await on_event({"type": "reasoning", "content": _text})
|
||||
elif _kind == "message":
|
||||
_final_msg = _text
|
||||
if _final_msg is not None:
|
||||
_final_msg = dict(_final_msg)
|
||||
if "reasoning_content" in _final_msg:
|
||||
_final_msg["reasoning"] = _final_msg.pop("reasoning_content")
|
||||
message = _final_msg
|
||||
else:
|
||||
message = {"content": _sc, "tool_calls": [], "reasoning": _sr}
|
||||
choice = {"message": message}
|
||||
data = {"choices": [choice], "usage": {}}
|
||||
@@ -5001,7 +4997,7 @@ async def _send_message_core(
|
||||
|
||||
if _think and tool_calls:
|
||||
|
||||
await _push_step({
|
||||
process_steps.append({
|
||||
|
||||
"type": "thinking",
|
||||
|
||||
@@ -5487,7 +5483,7 @@ async def _send_message_core(
|
||||
|
||||
called_mcp_names.append(_srv.get("name", ""))
|
||||
|
||||
await _push_step({
|
||||
process_steps.append({
|
||||
|
||||
"type": "mcp",
|
||||
|
||||
@@ -5509,14 +5505,18 @@ async def _send_message_core(
|
||||
|
||||
|
||||
|
||||
elif func_name == "install_app":
|
||||
async with span("app.install"):
|
||||
_iu = func_args.get("url", "") if isinstance(func_args, dict) else ""
|
||||
_itype = (func_args.get("type", "auto") if isinstance(func_args, dict) else "auto") or "auto"
|
||||
tool_result = _execute_install_app(db, _iu, current_user.id if current_user else None, _itype)
|
||||
process_steps.append({
|
||||
"type": "skill",
|
||||
"name": "安装应用",
|
||||
"args": func_args,
|
||||
"result": (tool_result or "")[:800],
|
||||
})
|
||||
else:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async with span(f"skill.execute:{func_name}"):
|
||||
|
||||
|
||||
@@ -5563,7 +5563,7 @@ async def _send_message_core(
|
||||
|
||||
called_skill_names.append(_sk.get("name", ""))
|
||||
|
||||
await _push_step({
|
||||
process_steps.append({
|
||||
|
||||
"type": "skill",
|
||||
|
||||
|
||||
@@ -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})。你现在可以直接在对话中使用它,或在应用市场查看。"
|
||||
Reference in new issue
Block a user