"""FastMCP 薄殼(AGENTS.md §8):search_wiki 與 read_page 兩個 tool。 查詢時只讀已編譯的 wiki 頁——不觸碰 raw 層、不做全庫掃描。 啟動(stdio):.venv/Scripts/python mcp/server.py 測試時可用環境變數 PP_QA_ROOT 指定專案根目錄。 """ import os import pathlib import sys ROOT = pathlib.Path(os.environ.get("PP_QA_ROOT", pathlib.Path(__file__).resolve().parents[1])) sys.path.insert(0, str(ROOT / "tools")) import kb import search as searchmod from fastmcp import FastMCP mcp = FastMCP("pp-qa-knowledge") def _truncate(body, max_tokens): # ponytail: token 估算用「1 token ≈ 1.5 字元」的 naive heuristic # (繁中約 1 字/token、英文約 4 字元/token 的折衷); # 升級路徑:以實際模型 tokenizer 校正。 limit = int(max_tokens * 1.5) return (body, False) if len(body) <= limit else (body[:limit], True) def search_wiki(query: str, top_k: int = 10) -> list: """BM25 檢索 wiki 頁,回傳 index 條目(path/title/description/tags)+ 相關度 score。 query 用繁體中文或英文關鍵詞。""" return searchmod.search(ROOT, query, max(1, min(int(top_k), 10))) # 上限 10(AGENTS.md §8) def read_page(path: str) -> dict: """讀取單一 wiki 頁全文:frontmatter + 內文(依 models.yaml token 上限截斷) + source_refs。path 例:wiki/entities/payment-gateway.md""" cfg = kb.load_config(ROOT) p = (ROOT / path).resolve() wiki = (ROOT / "wiki").resolve() # 信任邊界:擋路徑跳脫,只允許 wiki/ 下的 .md if not p.is_relative_to(wiki) or p.suffix != ".md": raise ValueError("path 須指向 wiki/ 下的 .md 頁面") if not p.is_file(): raise FileNotFoundError(f"頁面不存在:{path}") meta, body = kb.parse_page(p.read_text(encoding="utf-8")) body, truncated = _truncate(body, cfg["limits"]["mcp_response_max_tokens"]) return {"frontmatter": meta, "body": body, "truncated": truncated, "source_refs": [str(s) for s in meta.get("sources") or []]} mcp.tool(search_wiki) mcp.tool(read_page) if __name__ == "__main__": mcp.run()