更新
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
甄养堂 · 企微客服助手 MCP Server
|
||||
================================
|
||||
把企微 RPA 的配置、会话档案、AI 草稿能力暴露给 Cursor / Claude 等 MCP 客户端。
|
||||
|
||||
运行(stdio,供 Cursor 拉起):
|
||||
python mcp_server.py
|
||||
|
||||
本地调试:
|
||||
mcp dev mcp_server.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
# 保证可导入同目录模块
|
||||
_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
import ai_config
|
||||
from conversation_store import ConversationStore
|
||||
from ai_chat import call_ai_text
|
||||
|
||||
mcp = FastMCP(
|
||||
"wechat-rpa",
|
||||
instructions=(
|
||||
"甄养堂企微客服助手 MCP。"
|
||||
"可查询/修改 AI 配置、查看会话档案、用当前人设草稿回复。"
|
||||
"不会直接操控企业微信鼠标;自动回复仍由 wechat_gui / wechat_bot 负责。"
|
||||
),
|
||||
)
|
||||
|
||||
_STORE = ConversationStore(os.path.join(_ROOT, "conversations.json"))
|
||||
|
||||
|
||||
def _mask_key(key: str) -> str:
|
||||
if not key or len(key) < 10:
|
||||
return "***"
|
||||
return key[:6] + "…" + key[-4:]
|
||||
|
||||
|
||||
def _reload_store() -> ConversationStore:
|
||||
"""每次读取前重新加载,避免外部进程写盘后读到旧缓存。"""
|
||||
global _STORE
|
||||
_STORE = ConversationStore(os.path.join(_ROOT, "conversations.json"))
|
||||
return _STORE
|
||||
|
||||
|
||||
def _session_summaries(limit: int = 50) -> list[dict[str, Any]]:
|
||||
store = _reload_store()
|
||||
items = []
|
||||
for fp, entry in store._data.items():
|
||||
hist = entry.get("history") or []
|
||||
last = hist[-1] if hist else None
|
||||
items.append({
|
||||
"session_id": fp,
|
||||
"message_count": len(hist),
|
||||
"updated": entry.get("updated") or 0,
|
||||
"updated_iso": time.strftime(
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(entry["updated"]),
|
||||
) if entry.get("updated") else None,
|
||||
"last_role": (last or {}).get("role"),
|
||||
"last_content_preview": ((last or {}).get("content") or "")[:80],
|
||||
})
|
||||
items.sort(key=lambda x: x["updated"] or 0, reverse=True)
|
||||
return items[:limit]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Tools
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
def get_status() -> dict[str, Any]:
|
||||
"""获取企微窗口挂载状态、AI 开关、会话档案数量等总览。"""
|
||||
hwnd = 0
|
||||
title = ""
|
||||
try:
|
||||
import win32gui
|
||||
from wechat_bot import find_wx_hwnd, WX_WINDOW_CLASS
|
||||
hwnd = find_wx_hwnd()
|
||||
if hwnd:
|
||||
title = win32gui.GetWindowText(hwnd)
|
||||
cls = win32gui.GetClassName(hwnd)
|
||||
else:
|
||||
cls = ""
|
||||
except Exception as e:
|
||||
cls = f"error: {e}"
|
||||
|
||||
sessions = _session_summaries(limit=3)
|
||||
return {
|
||||
"wechat_found": bool(hwnd),
|
||||
"hwnd": f"0x{hwnd:08X}" if hwnd else None,
|
||||
"window_title": title or None,
|
||||
"window_class": cls or None,
|
||||
"ai_enabled": bool(ai_config.AI_ENABLED),
|
||||
"ai_model": ai_config.AI_MODEL,
|
||||
"agent_name": ai_config.AI_AGENT_NAME,
|
||||
"hospital": ai_config.AI_HOSPITAL_NAME,
|
||||
"context_enabled": bool(ai_config.AI_CONTEXT_ENABLED),
|
||||
"counter_insult": bool(ai_config.AI_COUNTER_INSULT_ENABLED),
|
||||
"session_count": len(_reload_store()._data),
|
||||
"recent_sessions": sessions,
|
||||
"project_root": _ROOT,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_ai_config(reveal_api_key: bool = False) -> dict[str, Any]:
|
||||
"""读取当前 AI 配置(默认脱敏 API Key;reveal_api_key=true 时显示完整 Key)。"""
|
||||
ai_config.load_settings()
|
||||
data = {k: getattr(ai_config, k) for k in ai_config.CONFIGURABLE_KEYS}
|
||||
if not reveal_api_key and "AI_API_KEY" in data:
|
||||
data["AI_API_KEY"] = _mask_key(str(data["AI_API_KEY"]))
|
||||
data["system_prompt_preview"] = ai_config.build_system_prompt()[:500]
|
||||
return data
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def update_ai_config(updates: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
更新 AI 配置并写入 ai_settings.json。
|
||||
只允许修改 CONFIGURABLE_KEYS 中的字段,例如:
|
||||
{"AI_AGENT_NAME": "高兴亮", "AI_COUNTER_INSULT_ENABLED": false, "AI_TEMPERATURE": 0.8}
|
||||
"""
|
||||
ai_config.load_settings()
|
||||
changed = {}
|
||||
ignored = []
|
||||
for k, v in (updates or {}).items():
|
||||
if k not in ai_config.CONFIGURABLE_KEYS:
|
||||
ignored.append(k)
|
||||
continue
|
||||
# 轻量类型校正
|
||||
cur = getattr(ai_config, k)
|
||||
try:
|
||||
if isinstance(cur, bool):
|
||||
if isinstance(v, str):
|
||||
v = v.strip().lower() in ("1", "true", "yes", "on")
|
||||
else:
|
||||
v = bool(v)
|
||||
elif isinstance(cur, int) and not isinstance(cur, bool):
|
||||
v = int(v)
|
||||
elif isinstance(cur, float):
|
||||
v = float(v)
|
||||
else:
|
||||
v = str(v)
|
||||
except Exception:
|
||||
ignored.append(k)
|
||||
continue
|
||||
setattr(ai_config, k, v)
|
||||
changed[k] = v if k != "AI_API_KEY" else _mask_key(str(v))
|
||||
|
||||
if "AI_AGENT_NAME" in changed or "AI_HOSPITAL_NAME" in changed:
|
||||
ai_config.AI_SYSTEM_PROMPT = ai_config.build_system_prompt()
|
||||
|
||||
ai_config.save_settings()
|
||||
return {"ok": True, "changed": changed, "ignored": ignored}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_system_prompt() -> str:
|
||||
"""返回当前渲染后的完整系统提示词(含对骂模式开关效果说明)。"""
|
||||
ai_config.load_settings()
|
||||
prompt = ai_config.build_system_prompt()
|
||||
if getattr(ai_config, "AI_COUNTER_INSULT_ENABLED", False):
|
||||
prompt += "\n" + getattr(ai_config, "AI_COUNTER_INSULT_PROMPT", "")
|
||||
return prompt
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_sessions(limit: int = 30) -> list[dict[str, Any]]:
|
||||
"""列出已持久化的会话档案(按最近更新排序)。session_id 为头像指纹 hex。"""
|
||||
return _session_summaries(limit=max(1, min(limit, 200)))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_session_history(session_id: str, max_messages: int = 40) -> dict[str, Any]:
|
||||
"""获取指定会话的对话历史。session_id 来自 list_sessions。"""
|
||||
store = _reload_store()
|
||||
if session_id not in store._data:
|
||||
return {"ok": False, "error": f"session not found: {session_id}"}
|
||||
hist = store.history(session_id)
|
||||
max_messages = max(1, min(max_messages, 200))
|
||||
slice_ = hist[-max_messages:]
|
||||
return {
|
||||
"ok": True,
|
||||
"session_id": session_id,
|
||||
"total": len(hist),
|
||||
"returned": len(slice_),
|
||||
"messages": [
|
||||
{
|
||||
"role": m.get("role"),
|
||||
"content": m.get("content"),
|
||||
"ts": m.get("ts"),
|
||||
"time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(m["ts"]))
|
||||
if m.get("ts") else None,
|
||||
}
|
||||
for m in slice_
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def clear_session(session_id: str) -> dict[str, Any]:
|
||||
"""清空并删除指定会话档案。"""
|
||||
store = _reload_store()
|
||||
if session_id not in store._data:
|
||||
return {"ok": False, "error": f"session not found: {session_id}"}
|
||||
del store._data[session_id]
|
||||
store.save()
|
||||
return {"ok": True, "deleted": session_id}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def draft_reply(
|
||||
message: str,
|
||||
session_id: Optional[str] = None,
|
||||
use_history: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
用当前客服人设生成一条草稿回复(不发送到企业微信)。
|
||||
可选传入 session_id,携带该会话档案历史上下文。
|
||||
"""
|
||||
if not (message or "").strip():
|
||||
return {"ok": False, "error": "message is empty"}
|
||||
|
||||
history = None
|
||||
if use_history and session_id:
|
||||
store = _reload_store()
|
||||
if session_id in store._data:
|
||||
history = store.history(session_id)
|
||||
|
||||
try:
|
||||
reply = call_ai_text(message.strip(), history=history)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"reply": reply,
|
||||
"session_id": session_id,
|
||||
"history_used": bool(history),
|
||||
"history_rounds": (len(history) // 2) if history else 0,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def append_session_message(
|
||||
session_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
向会话档案追加一条消息。role 必须是 user 或 assistant。
|
||||
用于人工校正上下文,或外部系统写入对话记录。
|
||||
"""
|
||||
if role not in ("user", "assistant"):
|
||||
return {"ok": False, "error": "role must be 'user' or 'assistant'"}
|
||||
if not content.strip():
|
||||
return {"ok": False, "error": "content is empty"}
|
||||
if not session_id.strip():
|
||||
return {"ok": False, "error": "session_id is empty"}
|
||||
|
||||
store = _reload_store()
|
||||
store.append(session_id.strip(), role, content.strip())
|
||||
store.save()
|
||||
return {
|
||||
"ok": True,
|
||||
"session_id": session_id,
|
||||
"message_count": len(store.history(session_id)),
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Resources
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.resource("wechat-rpa://status")
|
||||
def resource_status() -> str:
|
||||
"""总览状态 JSON。"""
|
||||
return json.dumps(get_status(), ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@mcp.resource("wechat-rpa://sessions")
|
||||
def resource_sessions() -> str:
|
||||
"""会话列表 JSON。"""
|
||||
return json.dumps(list_sessions(50), ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@mcp.resource("wechat-rpa://config")
|
||||
def resource_config() -> str:
|
||||
"""AI 配置 JSON(Key 脱敏)。"""
|
||||
return json.dumps(get_ai_config(False), ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@mcp.prompt()
|
||||
def customer_service_draft(customer_message: str) -> str:
|
||||
"""按甄养堂糖尿病客服人设,为客户消息起草回复。"""
|
||||
return (
|
||||
f"请以甄养堂互联网医院客服「{ai_config.AI_AGENT_NAME}」身份,"
|
||||
f"针对下面客户消息起草一条微信口语化回复(不要解释过程):\n\n"
|
||||
f"{customer_message}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
Reference in New Issue
Block a user