Ctrl K
ComunidadGuías…

scripts/xkb_recall_server.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""
XKB Recall MCP Server

MCP stdio server that exposes `xkb_recall` as a tool.
Works with any MCP-compatible agent: OpenClaw (via acpx), Claude Code, etc.

Protocol: JSON-RPC 2.0 over stdio (newline-delimited)

Tool: xkb_recall
  Input: { "message": "<user's current message>" }
  Output: Knowledge Service packet plus compatible results/formatted_text aliases
"""
from __future__ import annotations

import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
from xkb_recall import run_configured, compatibility_aliases
from xkb_delivery import diagnostic

RECALL_SCRIPT = Path(__file__).resolve().parent / "xkb_recall.py"

SERVER_INFO = {
    "name": "xkb-recall",
    "version": "1.0.0",
}

TOOL_DEF = {
    "name": "xkb_recall",
    "description": (
        "Recall relevant evidence from the shared XKB knowledge service before "
        "responding to substantive conversation: plans, difficulties, constraints, "
        "decisions and follow-ups, even without a question or search request. "
        "Provide recent conversation when the current message depends on it. "
        "Surface evidence only when it advances the current need; avoid repeating "
        "already-settled advice. Returns cards, wiki topics and conversation "
        "evidence with provenance. Uses the same retrieval, ACL, relevance judge and "
        "ranking as the HTTP API. Inspect retrieval_mode, judge and warnings: "
        "Use delivery.records for proactive suggestions; full records remain "
        "available for inspection even when delivery is background or degraded. "
        "keyword fallback or an unavailable judge are not semantic success. "
        "Results are candidates, not established answers; check their relevance "
        "and sources. Greetings are skipped. Failures are reported explicitly."
    ),
    "inputSchema": {
        "type": "object",
        "properties": {
            "message": {
                "type": "string",
                "description": "The user's current message to check for recall triggers.",
            },
            "conversation": {"type": "array", "maxItems": 8,
                             "description": "Recent user/assistant messages in chronological order, excluding the current message. Keep the active task and constraints.",
                             "items": {"type": "object", "properties": {
                                 "role": {"type": "string", "enum": ["user", "assistant"]},
                                 "content": {"type": "string"}}, "required": ["role", "content"]}},
            "limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10},
        },
        "required": ["message"],
    },
}


# A router failure and an honest "nothing matched" used to be indistinguishable:
# both surfaced as results=[]. That turned a missing-import bug into a 12-week
# silent outage (2026-05-04 → 2026-07-26). Every return path now carries an
# explicit `status`, and failures say so loudly enough for the agent to repeat
# it back to the user.
def _empty(status: str = "ok", error: str = "") -> dict:
    return {"trigger_class": "suppress", "state": "suppress", "delivery_mode": "none",
            "results": [], "confidence": 0.0, "formatted_text": "", "query": "",
            "status": status, "error": error}


def _failure(reason: str) -> dict:
    """A recall that could not run at all — never mistakable for 'no results'."""
    return {**_empty("failed", reason),
            "formatted_text": (
                "【XKB 知識庫查詢失敗】\n"
                f"原因:{reason}\n"
                "注意:這不代表知識庫沒有相關內容,而是查詢本身沒有執行成功。"
                "請明確告訴使用者這次回答並未使用知識庫。"
            )}


def _run_recall_structured(message: str, limit: int = 10, conversation=None) -> dict:
    """Expose the common runtime boundary through MCP's explicit error contract."""
    try:
        return compatibility_aliases(run_configured(message, limit, script=RECALL_SCRIPT, **({"conversation": conversation} if conversation is not None else {})))
    except Exception as exc:
        return _failure(str(exc))


def _respond(req_id, result=None, error=None):
    resp = {"jsonrpc": "2.0", "id": req_id}
    if error is not None:
        resp["error"] = error
    else:
        resp["result"] = result
    print(json.dumps(resp, ensure_ascii=False), flush=True)


def _notify(method: str, params=None):
    msg = {"jsonrpc": "2.0", "method": method}
    if params is not None:
        msg["params"] = params
    print(json.dumps(msg, ensure_ascii=False), flush=True)


def handle(req: dict):
    method = req.get("method", "")
    req_id = req.get("id")
    params = req.get("params") or {}

    # ── initialize ──────────────────────────────────────────────────────────
    if method == "initialize":
        _respond(req_id, {
            "protocolVersion": "2024-11-05",
            "capabilities": {"tools": {}},
            "serverInfo": SERVER_INFO,
        })
        return

    # ── initialized (notification, no response needed) ──────────────────────
    if method == "notifications/initialized":
        return

    # ── tools/list ──────────────────────────────────────────────────────────
    if method == "tools/list":
        _respond(req_id, {"tools": [TOOL_DEF]})
        return

    # ── tools/call ──────────────────────────────────────────────────────────
    if method == "tools/call":
        tool_name = params.get("name", "")
        arguments = params.get("arguments") or {}

        if tool_name != "xkb_recall":
            _respond(req_id, error={"code": -32602, "message": f"Unknown tool: {tool_name}"})
            return

        message = arguments.get("message", "")
        structured = _run_recall_structured(message, arguments.get("limit", 10), arguments.get("conversation"))
        # formatted_text for human-readable context injection
        text_output = structured.get("formatted_text", "")
        delivery = structured.get("delivery")
        if isinstance(delivery, dict):
            text_output = delivery.get("context", "")
            status = diagnostic(structured)
            if status:
                text_output = "\n\n".join(part for part in (status, text_output) if part)
            elif not text_output and structured.get("records"):
                text_output = "沒有足夠把握的新資訊值得主動提醒;候選與判斷狀態保留於 metadata,請依當前需求自行查閱。"
        # 提示放在回傳內容裡,不只放在 tool description——description 可能被截斷或忽略,
        # 而這句話決定了 agent 會不會把不相關的卡片當成使用者的知識講出來。
        if text_output:
            text_output = (
                "(以下是候選,不是答案。請依來源與查詢內容檢查相關性——"
                "請自行略過與問題無關的項目,不要當成使用者的知識引用。)\n\n"
                + text_output
            )
        # Full structured data as JSON annotation (agent can use it for routing decisions)
        _respond(req_id, {
            "content": [
                {"type": "text", "text": text_output},
                {"type": "text", "text": f"[xkb_recall_meta] {json.dumps(structured, ensure_ascii=False)}"},
            ],
            "isError": structured.get("status") == "failed",
        })
        return

    # ── ping ────────────────────────────────────────────────────────────────
    if method == "ping":
        _respond(req_id, {})
        return

    # ── unknown method ───────────────────────────────────────────────────────
    if req_id is not None:
        _respond(req_id, error={"code": -32601, "message": f"Method not found: {method}"})


def main():
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            req = json.loads(line)
        except json.JSONDecodeError:
            continue
        try:
            handle(req)
        except Exception as e:
            req_id = req.get("id") if isinstance(req, dict) else None
            if req_id is not None:
                _respond(req_id, error={"code": -32603, "message": str(e)})


import xkb_usage  # noqa: E402  — 量測誰在跑,見 scripts/xkb_usage.py

if __name__ == "__main__":
    xkb_usage.record(__file__)
    main()