- 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 单测)
105 lines
3.9 KiB
Python
105 lines
3.9 KiB
Python
"""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
|