- 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
340 lines
11 KiB
Python
340 lines
11 KiB
Python
#!/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-Key;Bearer 在本机版本会返回 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()
|