Files
mnote/scripts/mnote-lightrag-mcp-server.py
lix-2026 4a7efd4a30 feat: Page AI Reasonix desktop session alignment, settings IA cleanup, knowledge RAG hardening
- ACP client/session manager: Reasonix desktop live session context
- Hermes tools: knowledge_rag tool manifest and skill updates
- Browser runtime: sidebar page AI permission/profile/render/session/tree modules
- Routes: hermes_client, hermes_tools, knowledge_rag, web_shell
- Scripts: reasonix ACP wrapper, LightRAG MCP, smoke tasks 159/558/559/561/562
- Skills: mnote-knowledge-rag and mnote-lightrag-bridge SKILL.md updates
2026-06-13 22:20:01 +08:00

340 lines
11 KiB
Python
Raw Permalink 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.
#!/usr/bin/env python3
"""MNote LightRAG MCP facade.
This is intentionally thin: LightRAG owns retrieval, MNote owns citation/open
mapping through its source registry.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations
DEFAULT_ENV_FILE = "/mnt/Data1T/Mnote_data/lightrag/LightRAG/.env"
DEFAULT_MNOTE_WEB_URL = "http://127.0.0.1:3000"
DEFAULT_ROOT_URI = "file:///mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"
DEFAULT_WORKSPACE_ID = "local-ws:mnote-e2e:my-space"
mcp = FastMCP("MNote-LightRAG-Server")
READ_ONLY_TOOL = ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False)
def _read_env_value(key: str, env_file: str) -> str:
path = Path(env_file)
if not path.exists():
return ""
for line in path.read_text(encoding="utf-8").splitlines():
if not line.startswith(f"{key}="):
continue
value = line.split("=", 1)[1].strip()
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
):
value = value[1:-1]
return value
return ""
def _lightrag_base_url() -> str:
env_file = os.environ.get("MNOTE_LIGHTRAG_ENV_FILE", DEFAULT_ENV_FILE)
host = os.environ.get("LIGHTRAG_HOST") or _read_env_value("HOST", env_file) or "127.0.0.1"
port = os.environ.get("LIGHTRAG_PORT") or _read_env_value("PORT", env_file) or "9621"
if host in {"0.0.0.0", "::"}:
host = "127.0.0.1"
return f"http://{host}:{port}".rstrip("/")
def _lightrag_api_key() -> str:
env_file = os.environ.get("MNOTE_LIGHTRAG_ENV_FILE", DEFAULT_ENV_FILE)
return os.environ.get("LIGHTRAG_API_KEY") or _read_env_value("LIGHTRAG_API_KEY", env_file)
def _mnote_web_url() -> str:
return os.environ.get("MNOTE_WEB_URL", DEFAULT_MNOTE_WEB_URL).rstrip("/")
def _mnote_headers() -> dict[str, str]:
return {
"content-type": "application/json",
"x-mnote-actor-id": os.environ.get("MNOTE_ACTOR_ID", "mnote-e2e"),
"x-mnote-actor-type": os.environ.get("MNOTE_ACTOR_TYPE", "user"),
}
async def _request_lightrag(path: str, *, method: str = "GET", json_body: Any = None) -> Any:
headers = {"accept": "application/json"}
api_key = _lightrag_api_key()
if api_key:
# 当前 LightRAG /query 接受 X-API-KeyBearer 在本机版本会返回 Invalid token。
headers["X-API-Key"] = api_key
async with httpx.AsyncClient(timeout=180) as client:
response = await client.request(
method,
f"{_lightrag_base_url()}{path}",
headers=headers,
json=json_body,
)
try:
payload = response.json()
except Exception:
payload = {"text": response.text}
if response.status_code >= 400:
return {"status": "error", "response": None, "error": payload, "httpStatus": response.status_code}
return {"status": "success", "response": payload, "error": None, "httpStatus": response.status_code}
async def _request_mnote(path: str, *, method: str = "GET", json_body: Any = None, params: dict[str, Any] | None = None) -> Any:
headers = _mnote_headers()
if method.upper() == "GET":
headers = {key: value for key, value in headers.items() if key != "content-type"}
async with httpx.AsyncClient(timeout=180) as client:
response = await client.request(
method,
f"{_mnote_web_url()}{path}",
headers=headers,
json=json_body,
params=params,
)
try:
payload = response.json()
except Exception:
payload = {"text": response.text}
if response.status_code >= 400:
return {"status": "error", "response": None, "error": payload, "httpStatus": response.status_code}
return {"status": "success", "response": payload, "error": None, "httpStatus": response.status_code}
@mcp.tool(
name="connect",
description="No-op connection probe for agents that expect MCP servers to expose a connect tool.",
annotations=READ_ONLY_TOOL,
)
async def connect() -> Any:
return {
"status": "success",
"response": {
"server": "mnote_lightrag_bridge",
"connected": True,
},
"error": None,
"httpStatus": 200,
}
@mcp.tool(
name="verify_server_health",
description="Check whether the configured local LightRAG server is healthy.",
annotations=READ_ONLY_TOOL,
)
async def verify_server_health() -> Any:
return await _request_lightrag("/health")
@mcp.tool(
name="check_indexing_status",
description="Check the LightRAG document processing pipeline status.",
annotations=READ_ONLY_TOOL,
)
async def check_indexing_status() -> Any:
return await _request_lightrag("/documents/pipeline_status")
@mcp.tool(
name="list_all_docs",
description="List documents currently known to LightRAG.",
annotations=READ_ONLY_TOOL,
)
async def list_all_docs() -> Any:
return await _request_lightrag("/documents")
@mcp.tool(
name="query_knowledge_graph",
description="Ask MNote knowledge_rag.query. This calls MNote's LightRAG facade so references/citations are already mapped to MNote source registry and clickable locators.",
annotations=READ_ONLY_TOOL,
)
async def query_knowledge_graph(
prompt: str,
search_mode: str = "mix",
limit: int = 60,
include_chunk_content: bool = True,
include_document_structure_index: bool = False,
source_paths: list[str] | None = None,
workspace_id: str = DEFAULT_WORKSPACE_ID,
root_uri: str = DEFAULT_ROOT_URI,
) -> Any:
return await mnote_knowledge_rag_query(
query=prompt,
mode=search_mode,
top_k=limit,
chunk_top_k=limit,
include_chunk_content=include_chunk_content,
include_document_structure_index=include_document_structure_index,
source_paths=source_paths,
workspace_id=workspace_id,
root_uri=root_uri,
)
@mcp.tool(
name="mnote_knowledge_rag_status",
description="Call MNote mnote.knowledge_rag.status for provider health, source registry and sync state.",
annotations=READ_ONLY_TOOL,
)
async def mnote_knowledge_rag_status(
workspace_id: str = DEFAULT_WORKSPACE_ID,
root_uri: str = DEFAULT_ROOT_URI,
) -> Any:
return await _request_mnote(
"/api/knowledge-rag/status",
method="GET",
params={"workspaceId": workspace_id, "rootUri": root_uri},
)
@mcp.tool(
name="mnote_knowledge_rag_query",
description="Call MNote mnote.knowledge_rag.query. Use this for knowledge-library answers that need references/citations/clickable MNote locators. For book-like files pass source_paths and include_document_structure_index=true.",
annotations=READ_ONLY_TOOL,
)
async def mnote_knowledge_rag_query(
query: str,
mode: str = "mix",
top_k: int = 40,
chunk_top_k: int = 20,
include_chunk_content: bool = True,
include_document_structure_index: bool = False,
source_paths: list[str] | None = None,
workspace_id: str = DEFAULT_WORKSPACE_ID,
root_uri: str = DEFAULT_ROOT_URI,
) -> Any:
mode_aliases = {
"keyword": "naive",
"semantic": "hybrid",
}
resolved_mode = mode_aliases.get(mode, mode)
body = {
"workspaceId": workspace_id,
"rootUri": root_uri,
"query": query,
"mode": resolved_mode,
"topK": top_k,
"chunkTopK": chunk_top_k,
"includeChunkContent": include_chunk_content,
"includeDocumentStructureIndex": include_document_structure_index,
}
if source_paths:
body["sourcePaths"] = source_paths
return await _request_mnote("/api/knowledge-rag/query", method="POST", json_body=body)
@mcp.tool(
name="mnote_knowledge_rag_section_context",
description="Call MNote mnote.knowledge_rag.section_context. Use documentStructureIndex section ranges to fetch bounded LightRAG sidecar blocks/chunks for second-pass reading of book-like/long documents.",
annotations=READ_ONLY_TOOL,
)
async def mnote_knowledge_rag_section_context(
source_path: str = "",
source_id: str = "",
light_rag_doc_id: str = "",
file_path: str = "",
section_id: str = "",
start_block_ordinal: int | None = None,
end_block_ordinal: int | None = None,
start_paragraph_ordinal: int | None = None,
end_paragraph_ordinal: int | None = None,
context_before: int = 1,
context_after: int = 1,
max_blocks: int = 24,
max_chars: int = 12000,
workspace_id: str = DEFAULT_WORKSPACE_ID,
root_uri: str = DEFAULT_ROOT_URI,
) -> Any:
body: dict[str, Any] = {
"workspaceId": workspace_id,
"rootUri": root_uri,
"contextBefore": context_before,
"contextAfter": context_after,
"maxBlocks": max_blocks,
"maxChars": max_chars,
}
optional_values = {
"sourcePath": source_path,
"sourceId": source_id,
"lightRagDocId": light_rag_doc_id,
"filePath": file_path,
"sectionId": section_id,
"startBlockOrdinal": start_block_ordinal,
"endBlockOrdinal": end_block_ordinal,
"startParagraphOrdinal": start_paragraph_ordinal,
"endParagraphOrdinal": end_paragraph_ordinal,
}
for key, value in optional_values.items():
if value is not None and value != "":
body[key] = value
return await _request_mnote("/api/knowledge-rag/section-context", method="POST", json_body=body)
@mcp.tool(
name="open_mnote_reference",
description="Map a LightRAG reference/file_path/chunk_id to an MNote clickable citationUrl using MNote source registry.",
annotations=READ_ONLY_TOOL,
)
async def open_mnote_reference(
file_path: str,
chunk_id: str = "",
reference_id: str = "",
workspace_id: str = DEFAULT_WORKSPACE_ID,
root_uri: str = DEFAULT_ROOT_URI,
include_registry: bool = False,
) -> Any:
body: dict[str, Any] = {
"workspaceId": workspace_id,
"rootUri": root_uri,
"filePath": file_path,
}
if chunk_id:
body["chunkId"] = chunk_id
if reference_id:
body["referenceId"] = reference_id
result = await _request_mnote("/api/knowledge-rag/open-reference", method="POST", json_body=body)
payload = result.get("response")
if result.get("status") != "success":
return result
if not include_registry and isinstance(payload, dict):
payload = {key: value for key, value in payload.items() if key != "registry"}
return {"status": "success", "response": payload, "error": None, "httpStatus": result.get("httpStatus")}
@mcp.tool(
name="mnote_knowledge_rag_open_reference",
description="Call MNote mnote.knowledge_rag.open_reference to convert a returned reference/file_path/chunk_id into a clickable MNote locator.",
annotations=READ_ONLY_TOOL,
)
async def mnote_knowledge_rag_open_reference(
file_path: str,
chunk_id: str = "",
reference_id: str = "",
workspace_id: str = DEFAULT_WORKSPACE_ID,
root_uri: str = DEFAULT_ROOT_URI,
include_registry: bool = False,
) -> Any:
return await open_mnote_reference(
file_path=file_path,
chunk_id=chunk_id,
reference_id=reference_id,
workspace_id=workspace_id,
root_uri=root_uri,
include_registry=include_registry,
)
if __name__ == "__main__":
mcp.run()