317 lines
11 KiB
Python
317 lines
11 KiB
Python
"""
|
||
甄养堂 · 企微客服助手 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")
|