2026-06-09 09:20:56 +08:00
#!/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
2026-06-13 22:20:01 +08:00
from mcp.types import ToolAnnotations
2026-06-09 09:20:56 +08:00
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" )
2026-06-13 22:20:01 +08:00
READ_ONLY_TOOL = ToolAnnotations ( readOnlyHint = True , destructiveHint = False , idempotentHint = True , openWorldHint = False )
2026-06-09 09:20:56 +08:00
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"
2026-06-13 22:20:01 +08:00
if host in { "0.0.0.0" , "::" }:
host = "127.0.0.1"
2026-06-09 09:20:56 +08:00
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 }
2026-06-13 22:20:01 +08:00
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 ,
}
2026-06-09 09:20:56 +08:00
@mcp.tool (
name = "verify_server_health" ,
description = "Check whether the configured local LightRAG server is healthy." ,
2026-06-13 22:20:01 +08:00
annotations = READ_ONLY_TOOL ,
2026-06-09 09:20:56 +08:00
)
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." ,
2026-06-13 22:20:01 +08:00
annotations = READ_ONLY_TOOL ,
2026-06-09 09:20:56 +08:00
)
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." ,
2026-06-13 22:20:01 +08:00
annotations = READ_ONLY_TOOL ,
2026-06-09 09:20:56 +08:00
)
async def list_all_docs () -> Any :
return await _request_lightrag ( "/documents" )
@mcp.tool (
name = "query_knowledge_graph" ,
2026-06-13 22:20:01 +08:00
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 ,
2026-06-09 09:20:56 +08:00
)
async def query_knowledge_graph (
prompt : str ,
search_mode : str = "mix" ,
limit : int = 60 ,
include_chunk_content : bool = True ,
2026-06-13 22:20:01 +08:00
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 ,
2026-06-09 09:20:56 +08:00
) -> Any :
mode_aliases = {
"keyword" : "naive" ,
"semantic" : "hybrid" ,
}
2026-06-13 22:20:01 +08:00
resolved_mode = mode_aliases . get ( mode , mode )
2026-06-09 09:20:56 +08:00
body = {
2026-06-13 22:20:01 +08:00
"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 ,
2026-06-09 09:20:56 +08:00
}
2026-06-13 22:20:01 +08:00
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 )
2026-06-09 09:20:56 +08:00
@mcp.tool (
name = "open_mnote_reference" ,
description = "Map a LightRAG reference/file_path/chunk_id to an MNote clickable citationUrl using MNote source registry." ,
2026-06-13 22:20:01 +08:00
annotations = READ_ONLY_TOOL ,
2026-06-09 09:20:56 +08:00
)
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
2026-06-13 22:20:01 +08:00
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
2026-06-09 09:20:56 +08:00
if not include_registry and isinstance ( payload , dict ):
payload = { key : value for key , value in payload . items () if key != "registry" }
2026-06-13 22:20:01 +08:00
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 ,
)
2026-06-09 09:20:56 +08:00
if __name__ == "__main__" :
mcp . run ()