#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""倪海厦经方数字书库 · MCP 服务器（stdio）

让支持 MCP（Model Context Protocol）的 AI —— Claude Desktop / ChatGPT /
各类 Agent 框架 —— 把本站数据「安装」成自己的工具：

  · search_tcm      全站检索倪师讲稿（BM25，附出处）
  · list_books      书目列表
  · list_lessons    某书课程目录
  · get_lesson      课文纯文本（会员书未解锁部分只出试读）
  · search_acupoint 穴位解析（定位/主治）
  · get_meridian    经络循行与穴位列表

零第三方依赖：纯 Python 标准库（urllib 调远端开放 API，
JSON-RPC 2.0 over stdio 实现 MCP 协议）。

配置示例（Claude Desktop → claude_desktop_config.json）：
  {
    "mcpServers": {
      "nihaixia": {
        "command": "python",
        "args": ["mcp_server.py"],
        "env": {
          "NX_OPEN_BASE": "https://nihaixia.vip",
          "NX_OPEN_KEY": "你的key（可选，提升配额）"
        }
      }
    }
  }

协议：MCP 2024-11-05 stdio 传输（按行分隔的 JSON-RPC 2.0）。
日志只写 stderr，stdout 仅承载协议消息。
"""
import json
import os
import sys
import urllib.parse
import urllib.request

BASE = (os.environ.get("NX_OPEN_BASE") or "https://nihaixia.vip").rstrip("/")
API_KEY = (os.environ.get("NX_OPEN_KEY") or "").strip()
TIMEOUT = 30

PROTOCOL_VERSION = "2024-11-05"
SERVER_INFO = {"name": "nihaixia-tcm", "version": "1.0.0"}

TOOLS = [
    {
        "name": "search_tcm",
        "description": (
            "全站检索倪海厦人纪讲稿（针灸大成/伤寒论/金匮要略/神农本草经/汉唐方剂等）。"
            "返回最相关片段（含书名、课名、原文出处）。回答中医问题前先查这里，"
            "可显著减少编造。仅供学习参考，不构成医疗建议。"
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "q": {"type": "string", "description": "检索词，如 桂枝汤 / 小柴胡汤 / 失眠"},
                "top_k": {"type": "integer", "description": "返回条数，默认 6，上限 20"},
                "book": {"type": "string", "description": "可选书 id（先调 list_books 拿到）"},
            },
            "required": ["q"],
        },
    },
    {
        "name": "list_books",
        "description": "书目列表：书 id、书名、课程数、是否会员书。",
        "inputSchema": {"type": "object", "properties": {}},
    },
    {
        "name": "list_lessons",
        "description": "某书课程目录：课程 id 与标题。",
        "inputSchema": {
            "type": "object",
            "properties": {"book": {"type": "string", "description": "书 id"}},
            "required": ["book"],
        },
    },
    {
        "name": "get_lesson",
        "description": "取某节课的讲稿纯文本。会员书未解锁部分只返回试读段落。",
        "inputSchema": {
            "type": "object",
            "properties": {
                "book": {"type": "string", "description": "书 id"},
                "id": {"type": "string", "description": "课程 id（list_lessons 里拿到）"},
            },
            "required": ["book", "id"],
        },
    },
    {
        "name": "search_acupoint",
        "description": "穴位解析：定位、主治、针刺参考（结构化图谱数据）。",
        "inputSchema": {
            "type": "object",
            "properties": {"q": {"type": "string", "description": "穴名/拼音/编号，如 足三里 或 ST36"}},
            "required": ["q"],
        },
    },
    {
        "name": "get_meridian",
        "description": "经络循行路线与该经穴位列表。id 用全拼（zu_yang_ming_wei），缩写 st 或中文名 胃经 亦可。",
        "inputSchema": {
            "type": "object",
            "properties": {"id": {"type": "string", "description": "经络 id，如 st / gb / ren"}},
            "required": ["id"],
        },
    },
]


def log(msg):
    """调试日志走 stderr —— stdout 只承载协议消息。"""
    try:
        sys.stderr.write("[nihaixia-mcp] %s\n" % msg)
        sys.stderr.flush()
    except Exception:
        pass


def open_get(path, params=None):
    """调远端开放 API，返回 (status, dict)。"""
    qs = ("?" + urllib.parse.urlencode(params)) if params else ""
    url = BASE + path + qs
    req = urllib.request.Request(url, headers={
        "Accept": "application/json",
        "User-Agent": "nihaixia-mcp/1.0",
    })
    if API_KEY:
        req.add_header("X-Api-Key", API_KEY)
    with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
        return r.status, json.loads(r.read().decode("utf-8"))


def api_ok(payload):
    return bool(payload.get("ok"))


# ── 工具实现：每个返回 (text_for_ai, structured_dict) ──────────────

def tool_search_tcm(a):
    params = {"q": str(a.get("q") or ""), "top_k": int(a.get("top_k") or 6)}
    if a.get("book"):
        params["book"] = a["book"]
    st, body = open_get("/open/v1/search", params)
    if not api_ok(body):
        return "检索失败：" + str(body.get("error") or ("HTTP %s" % st)), None
    lines = []
    for i, it in enumerate(body.get("results") or [], 1):
        lines.append("%d. %s\n   %s\n   出处：%s" %
                     (i, it.get("title") or "(无题)",
                      (it.get("text") or "")[:200],
                      it.get("source") or (it.get("book") or "")))
    head = "共 %d 条结果：" % body.get("count", 0) if lines else "没有找到相关内容。"
    return head + "\n" + "\n".join(lines), body


def tool_list_books(a):
    st, body = open_get("/open/v1/books")
    if not api_ok(body):
        return "获取书目失败：" + str(body.get("error") or ("HTTP %s" % st)), None
    lines = []
    for b in body.get("books") or []:
        lock = "（会员书）" if b.get("locked") else ""
        lines.append("- %s《%s》%d 节 %s" % (b["id"], b.get("title") or "", b.get("lessons", 0), lock))
    return "共 %d 本书：\n%s" % (body.get("count", 0), "\n".join(lines)), body


def tool_list_lessons(a):
    book = str(a.get("book") or "")
    st, body = open_get("/open/v1/lessons", {"book": book})
    if not api_ok(body):
        return "获取课程目录失败：" + str(body.get("error") or ("HTTP %s" % st)), None
    lines = ["- %s《%s》" % (x.get("id"), x.get("title") or "")
             for x in body.get("lessons") or []]
    return "《%s》共 %d 节：\n%s" % (body.get("title") or book,
                                  body.get("count", 0), "\n".join(lines)), body


def tool_get_lesson(a):
    st, body = open_get("/open/v1/lesson", {"book": a.get("book") or "",
                                            "id": a.get("id") or ""})
    if not api_ok(body):
        return "获取课文失败：" + str(body.get("error") or ("HTTP %s" % st)), None
    head = "《%s》· %s《%s》" % (body.get("course") or "", body.get("chapter_title") or "", body.get("title") or "")
    out = head + "\n" + str(body.get("text") or "")
    if body.get("locked") and not body.get("full"):
        out += "\n\n（%s）" % (body.get("note") or "完整讲稿为会员内容。")
    return out, body


def tool_search_acupoint(a):
    q = str(a.get("q") or "")
    st, body = open_get("/open/v1/acupoint", {"q": q})
    if not api_ok(body):
        return "穴位查询失败：" + str(body.get("error") or ("HTTP %s" % st)), None
    return json.dumps(body.get("resolved"), ensure_ascii=False), body


def tool_get_meridian(a):
    st, body = open_get("/open/v1/meridian", {"id": a.get("id") or ""})
    if not api_ok(body):
        return "经络查询失败：" + str(body.get("error") or ("HTTP %s" % st)), None
    return json.dumps({"meridian": body.get("meridian"),
                       "route": body.get("route")}, ensure_ascii=False), body


TOOL_IMPL = {
    "search_tcm": tool_search_tcm,
    "list_books": tool_list_books,
    "list_lessons": tool_list_lessons,
    "get_lesson": tool_get_lesson,
    "search_acupoint": tool_search_acupoint,
    "get_meridian": tool_get_meridian,
}


def write_msg(msg):
    sys.stdout.write(json.dumps(msg, ensure_ascii=False) + "\n")
    sys.stdout.flush()


def reply(req_id, result):
    write_msg({"jsonrpc": "2.0", "id": req_id, "result": result})


def reply_err(req_id, code, message):
    write_msg({"jsonrpc": "2.0", "id": req_id,
               "error": {"code": code, "message": message}})


def handle(req):
    method = req.get("method") or ""
    req_id = req.get("id")
    is_notify = req_id is None

    if method == "initialize":
        reply(req_id, {
            "protocolVersion": req.get("params", {}).get("protocolVersion") or PROTOCOL_VERSION,
            "capabilities": {"tools": {}},
            "serverInfo": SERVER_INFO,
        })
        return
    if method == "notifications/initialized" or method.startswith("notifications/"):
        return                                   # 通知：无需回复
    if method == "ping":
        reply(req_id, {})
        return
    if method == "tools/list":
        reply(req_id, {"tools": TOOLS})
        return
    if method == "tools/call":
        params = req.get("params") or {}
        name = params.get("name") or ""
        args = params.get("arguments") or {}
        fn = TOOL_IMPL.get(name)
        if not fn:
            reply_err(req_id, -32602, "未知工具: " + name)
            return
        try:
            text, structured = fn(args)
            result = {"content": [{"type": "text", "text": text}], "isError": False}
            reply(req_id, result)
        except Exception as e:
            log("tool %s 异常: %s" % (name, e))
            reply(req_id, {"content": [{"type": "text", "text": "工具执行失败: " + str(e)}],
                           "isError": True})
        return
    if is_notify:
        return
    reply_err(req_id, -32601, "未知方法: " + method)


def main():
    # MCP 规范：stdio 消息必须是 UTF-8。Windows 管道输出默认走 GBK，
    # 不 reconfigure 的话中文工具描述会把协议流污染成非法 UTF-8（2026-11-01 实测）。
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8")
    if hasattr(sys.stderr, "reconfigure"):
        sys.stderr.reconfigure(encoding="utf-8")
    log("启动：BASE=%s key=%s" % (BASE, "已配置" if API_KEY else "未配置（匿名限额）"))
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            req = json.loads(line)
        except ValueError as e:
            write_msg({"jsonrpc": "2.0", "id": None,
                       "error": {"code": -32700, "message": "解析错误: " + str(e)}})
            continue
        try:
            handle(req)
        except Exception as e:
            log("处理异常: %s" % e)
            if req.get("id") is not None:
                reply_err(req.get("id"), -32603, "内部错误: " + str(e))


if __name__ == "__main__":
    main()
