This commit is contained in:
Your Name
2026-07-16 12:11:05 +08:00
parent 22ec32eb29
commit 32895b1591
41 changed files with 5740 additions and 0 deletions
+316
View File
@@ -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 Keyreveal_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 配置 JSONKey 脱敏)。"""
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")