Files

340 lines
11 KiB
Python
Raw Permalink Normal View History

#!/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()