Files
PlantAI-rag-new/sdk/app/services/paper/mcp_client.py
T
admin 91f6a1c242 feat: 新增论文撰写功能 paper-writer(独立 MCP 服务 + BFF 大脑 + 前端三卡)
- paper_writer_service(8097):FastAPI + MCP streamable-http 单端点,9 工具
  (propose_paper_outline/write_paper_section/get_paper_progress/search_refs/
  revise_paper_section/revise_paper/get_paper/complete_paper/cancel_paper),
  SQLite 三表(papers/paper_sections/citations,WAL),GB/T 7714 引用协议
- BFF:services/paper/{mcp_client,agent}.py(裸 JSON-RPC 客户端 + 规则状态机)、
  routers/paper.py、schemas.py 4 个 Paper*Block + ArtifactBlock 加 paper_id/citation_count、
  config.py 开关、main.py 条件注册、docgen.py 抽 insert_artifact 公共 helper
- 前端:intent.js paperIntent 纯函数、Composer docgen 分支 sub-route、sendPaper/轮询、
  PaperBriefCard/PaperOutlineCard/PaperProgressCard、DocPane cite-chip
- deploy/paper_restart.sh + toggle_paper.sh;compile_sdk.sh 白名单追加 paper 文件
- tests/paper 26 条(工具逻辑 + MCP 协议 + BFF 状态机 + 块 schema + 前端 intent 单测)
2026-09-30 22:29:22 +08:00

105 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""services/paper/mcp_client.py —— 裸 JSON-RPC over httpx 客户端(BFF pyproject 无 mcp 依赖,零新增)。
与 paper_writer_service /mcp/ 端点通信:initialize → tools/call。
响应为 SSE(text/event-stream):`event: message\ndata: <json>\n\n`;会话由 mcp-session-id 头维系。
端点注意:/mcp 无斜杠会 307 重定向,必须请求 /mcp/。
"""
from __future__ import annotations
import json
import logging
from typing import Any
import httpx
logger = logging.getLogger(__name__)
PROTOCOL_VERSION = "2025-03-26"
class PaperMcpError(Exception):
"""paper 服务 RPC/工具调用失败。"""
def __init__(self, message: str, *, cause: BaseException | None = None) -> None:
super().__init__(message)
self.cause = cause
def _parse_sse_body(text: str) -> dict[str, Any] | None:
"""从 SSE 响应抽取 `data:` 行的 JSON;非 SSE 则整段按 JSON 解析。"""
for line in text.splitlines():
line = line.strip()
if line.startswith("data:"):
try:
return json.loads(line[5:].strip())
except ValueError:
return None
try:
return json.loads(text)
except ValueError:
return None
class PaperMcpClient:
"""paper 服务 MCP 客户端:维护 mcp-session-id,逐个 JSON-RPC 调用。"""
def __init__(self, base_url: str, timeout: float = 360.0) -> None:
self.base_url = (base_url or "http://127.0.0.1:8097").rstrip("/")
self.timeout = timeout
self._session_id: str | None = None
self._rpc_id = 0
self._initialized = False
async def _post(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
self._rpc_id += 1
body = {"jsonrpc": "2.0", "id": self._rpc_id, "method": method, "params": params}
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
if self._session_id:
headers["mcp-session-id"] = self._session_id
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.post(f"{self.base_url}/mcp/", headers=headers, json=body)
resp.raise_for_status()
sid = resp.headers.get("mcp-session-id")
if sid:
self._session_id = sid
text = resp.text
payload = _parse_sse_body(text)
if payload is None:
raise PaperMcpError(f"paper 服务响应无法解析:{text[:200]}")
if payload.get("error"):
raise PaperMcpError(f"paper 服务 RPC 错误:{payload['error']}")
return payload.get("result") or {}
async def initialize(self) -> dict[str, Any]:
return await self._post("initialize", {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "plantai-bff-paper", "version": "0.1.0"},
})
async def _ensure_initialized(self) -> None:
"""MCP streamable-http 要求先 initialize(建立会话 + 取 mcp-session-id),
否则 tools/call 会被服务端 400 拒绝。"""
if not self._initialized:
await self.initialize()
self._initialized = True
async def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
await self._ensure_initialized()
result = await self._post("tools/call", {"name": name, "arguments": arguments})
if result.get("isError"):
raise PaperMcpError(f"工具 {name} 返回错误:{result}")
sc = result.get("structuredContent")
if sc is not None:
return sc
for block in result.get("content") or []:
if isinstance(block, dict) and block.get("type") == "text" and block.get("text"):
try:
return json.loads(block["text"])
except ValueError:
return {"raw": block["text"]}
return result