guanghulab/notion/notion-mcp-server.py

736 lines
28 KiB
Python
Raw 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
"""
Notion MCP Server · 光湖铸渊 Notion 连接器
通过 Notion OAuth 授权,让铸渊可以:
- ✅ 读取用户按语言路径指定的页面
- ✅ 在指定位置新建页面
- ❌ 不编辑/删除/更改现有页面
- ❌ 默认不读取任何页面(只读明确给的路径)
端口3915
"""
import json, os, time, hmac, hashlib, base64, uuid, logging
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs, urlencode
import urllib.request, urllib.error
# ═══════════════════════════════════════════
# 配置
# ═══════════════════════════════════════════
PORT = int(os.environ.get("NOTION_MCP_PORT", "3915"))
DATA_DIR = os.environ.get("NOTION_DATA_DIR", "/opt/guanghulab-repo/notion")
# Notion OAuth - 用户需在 notion.so/my-integrations 注册后填写
NOTION_CLIENT_ID = os.environ.get("NOTION_CLIENT_ID", "")
NOTION_CLIENT_SECRET = os.environ.get("NOTION_CLIENT_SECRET", "")
# 回调地址(需与 Notion 注册的一致)
BASE_URL = "https://guanghulab.com"
REDIRECT_URI = f"{BASE_URL}/api/notion/oauth/callback"
# ═══════════════════════════════════════════
# 令牌存储
# ═══════════════════════════════════════════
TOKENS_FILE = os.path.join(DATA_DIR, "tokens.json")
STATES_FILE = os.path.join(DATA_DIR, "states.json")
def ensure_dir():
os.makedirs(DATA_DIR, exist_ok=True)
def load_json(path, default=None):
try:
with open(path, "r") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return default if default is not None else {}
def save_json(path, data):
with open(path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def get_tokens():
return load_json(TOKENS_FILE, {})
def save_token(user_key, token_data):
tokens = get_tokens()
tokens[user_key] = token_data
save_json(TOKENS_FILE, tokens)
def remove_token(user_key):
tokens = get_tokens()
tokens.pop(user_key, None)
save_json(TOKENS_FILE, tokens)
def get_states():
return load_json(STATES_FILE, {})
def save_state(state, data):
states = get_states()
states[state] = data
save_json(STATES_FILE, states)
def remove_state(state):
states = get_states()
states.pop(state, None)
save_json(STATES_FILE, states)
# ═══════════════════════════════════════════
# Notion API 调用
# ═══════════════════════════════════════════
NOTION_API = "https://api.notion.com/v1"
NOTION_VERSION = "2022-06-28"
def notion_headers(token):
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Notion-Version": NOTION_VERSION,
}
def notion_get(path, token):
req = urllib.request.Request(f"{NOTION_API}{path}", headers=notion_headers(token))
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
def notion_post(path, data, token):
body = json.dumps(data).encode()
req = urllib.request.Request(f"{NOTION_API}{path}", data=body, headers=notion_headers(token))
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
err = e.read().decode()[:500]
raise Exception(f"Notion API {e.code}: {err}")
def notion_patch(path, data, token):
"""Notion PATCH - NOT exposed to 铸渊 (for safety)"""
req = urllib.request.Request(f"{NOTION_API}{path}", method="PATCH")
req.data = json.dumps(data).encode()
for k, v in notion_headers(token).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
err = e.read().decode()[:500]
raise Exception(f"Notion API {e.code}: {err}")
# ═══════════════════════════════════════════
# 权限检查
# ═══════════════════════════════════════════
def check_read_permission(page_id, token, context=None):
"""
权限检查:
- 铸渊只能读取明确指定的页面
- 默认不读取任何内容
- 冰朔的 Notion 除非冰朔明确给出路径,否则不读取
"""
# context 可以包含 "user" 字段
# 如果是冰朔用户,检查是否在允许的路径列表内
if context and context.get("user") == "bingshuo":
allowed = context.get("allowed_paths", [])
if allowed and page_id not in allowed:
raise PermissionError(f"此页面不在当前允许的语言路径中")
return True
# ═══════════════════════════════════════════
# HTTP 处理器
# ═══════════════════════════════════════════
def json_response(resp, status, data):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
resp.send_response(status)
resp.send_header("Content-Type", "application/json; charset=utf-8")
resp.send_header("Access-Control-Allow-Origin", "*")
resp.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
resp.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
resp.end_headers()
resp.wfile.write(body)
def redirect(resp, url):
resp.send_response(302)
resp.send_header("Location", url)
resp.end_headers()
def read_body(req):
length = int(req.headers.get("Content-Length", 0))
if length == 0:
return {}
body = req.rfile.read(length)
return json.loads(body)
class NotionMCPHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
logging.info(f"{self.client_address[0]} - {format % args}")
def do_OPTIONS(self):
json_response(self, 204, {})
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
params = parse_qs(parsed.query)
# NOTE: Nginx strips /api/notion/ prefix, so what we receive is the remaining path
# e.g., /api/notion/status → /status (if proxy_pass has trailing /)
# But to be safe, we handle both with and without prefix
try:
if path == "/connect" or path == "/api/notion/connect":
self.handle_connect(params)
elif path == "/oauth/callback" or path == "/api/notion/oauth/callback":
self.handle_oauth_callback(params)
elif path == "/status" or path == "/api/notion/status":
self.handle_status()
elif path == "/disconnect" or path == "/api/notion/disconnect":
self.handle_disconnect(params)
else:
json_response(self, 404, {"ok": False, "error": f"未知路径: {path}"})
except Exception as e:
logging.error(f"GET {path}: {e}")
json_response(self, 500, {"ok": False, "error": str(e)})
def do_POST(self):
parsed = urlparse(self.path)
path = parsed.path
try:
body = read_body(self)
if path == "/read" or path == "/api/notion/read":
self.handle_read(body)
elif path == "/create" or path == "/api/notion/create":
self.handle_create(body)
elif path == "/search" or path == "/api/notion/search":
self.handle_search(body)
elif path == "/list" or path == "/api/notion/list":
self.handle_list(body)
else:
json_response(self, 404, {"ok": False, "error": f"未知路径: {path}"})
except PermissionError as e:
json_response(self, 403, {"ok": False, "error": str(e)})
except Exception as e:
logging.error(f"POST {path}: {e}")
json_response(self, 500, {"ok": False, "error": str(e)})
# ── 连接 ──
def handle_connect(self, params):
"""
用户点击"连接 Notion" → 生成 OAuth URL 并跳转
?user_key=xxx — 可选,标识连接的用户
"""
user_key = params.get("user_key", [None])[0]
if not user_key:
# 生成一个临时 user_key
user_key = f"user_{uuid.uuid4().hex[:8]}"
if not NOTION_CLIENT_ID:
json_response(self, 400, {
"ok": False,
"error": "Notion OAuth 尚未配置,请联系管理员在环境变量中设置 NOTION_CLIENT_ID",
})
return
state = uuid.uuid4().hex[:16]
save_state(state, {"user_key": user_key, "created_at": time.time()})
oauth_url = (
f"https://api.notion.com/v1/oauth/authorize"
f"?client_id={NOTION_CLIENT_ID}"
f"&response_type=code"
f"&redirect_uri={REDIRECT_URI}"
f"&state={state}"
)
redirect(self, oauth_url)
def handle_oauth_callback(self, params):
"""Notion 回调处理"""
code = params.get("code", [None])[0]
state = params.get("state", [None])[0]
error = params.get("error", [None])[0]
if error:
json_response(self, 400, {"ok": False, "error": f"用户取消了授权"})
return
if not code or not state:
json_response(self, 400, {"ok": False, "error": "缺少参数"})
return
# 验证 state
states = get_states()
state_data = states.get(state)
if not state_data:
json_response(self, 400, {"ok": False, "error": "state 无效或已过期"})
return
user_key = state_data["user_key"]
remove_state(state)
# 交换 code 获取 token
try:
auth = base64.b64encode(
f"{NOTION_CLIENT_ID}:{NOTION_CLIENT_SECRET}".encode()
).decode()
token_req = urllib.request.Request(
"https://api.notion.com/v1/oauth/token",
data=json.dumps({
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
}).encode(),
headers={
"Authorization": f"Basic {auth}",
"Content-Type": "application/json",
}
)
with urllib.request.urlopen(token_req, timeout=15) as resp:
token_data = json.loads(resp.read())
except urllib.error.HTTPError as e:
err = e.read().decode()[:500]
json_response(self, 400, {"ok": False, "error": f"Token 交换失败: {err}"})
return
# 存储 token
save_token(user_key, {
"access_token": token_data["access_token"],
"bot_id": token_data.get("bot_id", ""),
"workspace_name": token_data.get("workspace_name", ""),
"workspace_icon": token_data.get("workspace_icon", ""),
"owner": token_data.get("owner", {}),
"created_at": time.time(),
})
# 重定向回首页(带成功消息)
redirect(self, f"{BASE_URL}/?notion=connected&user={user_key}")
# ── 状态 ──
def handle_status(self):
"""返回连接状态"""
tokens = get_tokens()
connected_users = []
for user_key, data in tokens.items():
connected_users.append({
"user": user_key,
"workspace": data.get("workspace_name", "未知工作区"),
"connected_at": data.get("created_at", 0),
})
json_response(self, 200, {
"ok": True,
"configured": bool(NOTION_CLIENT_ID),
"connected_users": connected_users,
"count": len(connected_users),
})
# ── 断开连接 ──
def handle_disconnect(self, params):
user_key = params.get("user_key", [None])[0]
if not user_key:
json_response(self, 400, {"ok": False, "error": "缺少 user_key"})
return
remove_token(user_key)
json_response(self, 200, {"ok": True, "message": f"用户 {user_key} 已断开连接"})
# ── 读取页面 ⭐ ──
def handle_read(self, body):
"""
读取 Notion 页面
请求:
{ "page_id": "xxx", "user": "bingshuo", "allowed_paths": ["xxx"] }
权限:
- 未指定 allowed_paths 时,铸渊不读取任何页面
- 冰朔的 Notion 需要显式 allowed_paths 才能读
"""
page_id = body.get("page_id", "")
user = body.get("user", "")
allowed_paths = body.get("allowed_paths", None)
if not page_id:
json_response(self, 400, {"ok": False, "error": "缺少 page_id"})
return
# 权限检查:未指定 allowed_paths 时拒绝读取
if allowed_paths is None:
json_response(self, 403, {
"ok": False,
"error": "未指定语言路径,铸渊默认不读取任何页面",
})
return
if user == "bingshuo" and allowed_paths is not None and page_id not in allowed_paths:
json_response(self, 403, {
"ok": False,
"error": "此页面不在当前允许的语言路径中",
})
return
# 获取 token
tokens = get_tokens()
token_entry = tokens.get(user) or tokens.get("default") or next(iter(tokens.values()), None)
if not token_entry:
json_response(self, 401, {
"ok": False,
"error": f"用户 {user or '默认'} 尚未连接 Notion",
})
return
access_token = token_entry["access_token"]
try:
# 读取页面
page = notion_get(f"/pages/{page_id}", access_token)
# 读取页面内容blocks
blocks = notion_get(f"/blocks/{page_id}/children", access_token)
result = {
"ok": True,
"page": {
"id": page.get("id"),
"title": self._extract_title(page),
"url": page.get("url", ""),
"created_time": page.get("created_time", ""),
"last_edited_time": page.get("last_edited_time", ""),
},
"blocks": [],
}
for block in blocks.get("results", []):
block_type = block.get("type", "unsupported")
block_content = self._extract_block_content(block)
if block_content:
result["blocks"].append({
"id": block.get("id"),
"type": block_type,
"content": block_content,
})
json_response(self, 200, result)
except urllib.error.HTTPError as e:
err = e.read().decode()[:300]
json_response(self, 500, {"ok": False, "error": f"读取失败: {err}"})
except Exception as e:
json_response(self, 500, {"ok": False, "error": f"读取失败: {e}"})
# ── 新建页面 ⭐ ──
def handle_create(self, body):
"""
在指定位置新建页面
请求:
{
"parent_id": "xxx", // 父页面 ID
"title": "新页面标题",
"content": "页面内容(支持 Markdown 格式)",
"user": "bingshuo" // 可选,用哪个用户的 Notion
}
限制:
- 只能新建,不能编辑/删除已有页面
"""
parent_id = body.get("parent_id", "")
title = body.get("title", "无标题")
content = body.get("content", "")
user = body.get("user", "")
if not parent_id:
json_response(self, 400, {"ok": False, "error": "缺少 parent_id"})
return
tokens = get_tokens()
token_entry = tokens.get(user) or tokens.get("default") or next(iter(tokens.values()), None)
if not token_entry:
json_response(self, 401, {
"ok": False,
"error": f"用户 {user or '默认'} 尚未连接 Notion",
})
return
access_token = token_entry["access_token"]
try:
# 构建创建请求(只支持新建,编辑接口不开放)
create_data = {
"parent": {"page_id": parent_id},
"properties": {
"title": {
"title": [{"type": "text", "text": {"content": title}}]
}
},
"children": self._parse_content_to_blocks(content),
}
result = notion_post("/pages", create_data, access_token)
json_response(self, 200, {
"ok": True,
"page_id": result.get("id"),
"url": result.get("url", ""),
"title": title,
})
except urllib.error.HTTPError as e:
err = e.read().decode()[:500]
json_response(self, 500, {"ok": False, "error": f"创建失败: {err}"})
except Exception as e:
json_response(self, 500, {"ok": False, "error": f"创建失败: {e}"})
# ── 搜索页面 ──
def handle_search(self, body):
"""搜索 Notion 页面(按标题关键词)"""
query = body.get("query", "")
user = body.get("user", "")
if not query:
json_response(self, 400, {"ok": False, "error": "缺少查询关键词"})
return
tokens = get_tokens()
token_entry = tokens.get(user) or tokens.get("default") or next(iter(tokens.values()), None)
if not token_entry:
json_response(self, 401, {"ok": False, "error": "用户尚未连接 Notion"})
return
access_token = token_entry["access_token"]
try:
result = notion_post("/search", {
"query": query,
"filter": {"value": "page", "property": "object"},
"page_size": 10,
}, access_token)
pages = []
for item in result.get("results", []):
pages.append({
"id": item.get("id"),
"title": self._extract_title(item),
"url": item.get("url", ""),
})
json_response(self, 200, {"ok": True, "pages": pages})
except Exception as e:
json_response(self, 500, {"ok": False, "error": f"搜索失败: {e}"})
# ── 子页面列表 ──
def handle_list(self, body):
"""列出指定页面下的子页面"""
page_id = body.get("page_id", "")
user = body.get("user", "")
if not page_id:
json_response(self, 400, {"ok": False, "error": "缺少 page_id"})
return
tokens = get_tokens()
token_entry = tokens.get(user) or tokens.get("default") or next(iter(tokens.values()), None)
if not token_entry:
json_response(self, 401, {"ok": False, "error": "用户尚未连接 Notion"})
return
access_token = token_entry["access_token"]
try:
blocks = notion_get(f"/blocks/{page_id}/children", access_token)
child_pages = []
for block in blocks.get("results", []):
if block.get("type") == "child_page":
child_pages.append({
"id": block.get("id"),
"title": block.get("child_page", {}).get("title", "未命名"),
})
json_response(self, 200, {"ok": True, "pages": child_pages})
except Exception as e:
json_response(self, 500, {"ok": False, "error": f"列表获取失败: {e}"})
# ── 工具函数 ──
def _extract_title(self, page):
"""从 page 对象提取标题"""
props = page.get("properties", {})
for prop in props.values():
if prop.get("type") == "title":
titles = prop.get("title", [])
if titles:
return titles[0].get("plain_text", "")
return page.get("id", "未命名页面")
def _extract_block_content(self, block):
"""解析 block 内容为文本"""
btype = block.get("type", "")
bdata = block.get(btype, {})
if btype in ("paragraph", "heading_1", "heading_2", "heading_3", "quote", "callout",
"bulleted_list_item", "numbered_list_item", "to_do", "toggle"):
texts = []
for rt in bdata.get("rich_text", []):
texts.append(rt.get("plain_text", ""))
return "".join(texts)
if btype == "code":
texts = []
for rt in bdata.get("rich_text", []):
texts.append(rt.get("plain_text", ""))
return "\n".join(texts)
if btype == "divider":
return "---"
if btype == "child_page":
return f"[子页面] {bdata.get('title', '')}"
return None
def _parse_content_to_blocks(self, content):
"""将文本内容转换为 Notion blocks只支持新建只追加不修改"""
blocks = []
for line in content.split("\n"):
line = line.strip()
if not line:
continue
# Heading 1
if line.startswith("# "):
blocks.append({
"object": "block",
"type": "heading_1",
"heading_1": {
"rich_text": [{"type": "text", "text": {"content": line[2:]}}]
}
})
# Heading 2
elif line.startswith("## "):
blocks.append({
"object": "block",
"type": "heading_2",
"heading_2": {
"rich_text": [{"type": "text", "text": {"content": line[3:]}}]
}
})
# Heading 3
elif line.startswith("### "):
blocks.append({
"object": "block",
"type": "heading_3",
"heading_3": {
"rich_text": [{"type": "text", "text": {"content": line[4:]}}]
}
})
# Bullet list
elif line.startswith("- ") or line.startswith("* "):
blocks.append({
"object": "block",
"type": "bulleted_list_item",
"bulleted_list_item": {
"rich_text": [{"type": "text", "text": {"content": line[2:]}}]
}
})
# Numbered list
elif line[0].isdigit() and ". " in line[:4]:
text = line.split(". ", 1)[1] if ". " in line else line
blocks.append({
"object": "block",
"type": "numbered_list_item",
"numbered_list_item": {
"rich_text": [{"type": "text", "text": {"content": text}}]
}
})
# Quote
elif line.startswith("> "):
blocks.append({
"object": "block",
"type": "quote",
"quote": {
"rich_text": [{"type": "text", "text": {"content": line[2:]}}]
}
})
# Code block
elif line.startswith("```"):
code_lines = []
while True:
break
blocks.append({
"object": "block",
"type": "code",
"code": {
"rich_text": [{"type": "text", "text": {"content": line}}],
"language": "plain text"
}
})
# Divider
elif line == "---":
blocks.append({"object": "block", "type": "divider", "divider": {}})
# Paragraph (default)
else:
blocks.append({
"object": "block",
"type": "paragraph",
"paragraph": {
"rich_text": [{"type": "text", "text": {"content": line}}]
}
})
return blocks if blocks else [{
"object": "block",
"type": "paragraph",
"paragraph": {"rich_text": [{"type": "text", "text": {"content": " "}}]}
}]
# ═══════════════════════════════════════════
# 启动脚本
# ═══════════════════════════════════════════
def generate_setup_guide():
"""打印配置指南"""
print(f"""
╔══════════════════════════════════════════════════════╗
║ 🔗 光湖 · Notion MCP Server ║
║ 铸渊 Notion 连接器 ║
╠══════════════════════════════════════════════════════╣
║ ║
║ 端口: {PORT}
║ 数据: {DATA_DIR}
║ ║
║ ⚠ 首次启动前,请管理员完成以下操作: ║
║ ║
║ 1. 访问 https://www.notion.so/my-integrations ║
║ 2. 新建 Public Integration (OAuth) ║
║ 3. 填写回调地址: ║
{REDIRECT_URI}
║ 4. 获取 Client ID 和 Client Secret ║
║ 5. 设置环境变量: ║
║ export NOTION_CLIENT_ID=你的ID ║
║ export NOTION_CLIENT_SECRET=你的Secret ║
║ export NOTION_MCP_PORT=3915 ║
║ ║
╚══════════════════════════════════════════════════════╝
""")
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO,
format="[%(asctime)s] %(levelname)s %(message)s")
ensure_dir()
if not NOTION_CLIENT_ID:
generate_setup_guide()
print("⚠ 缺少 NOTION_CLIENT_IDOAuth 流程将不可用")
print(" 服务器仍可启动,但 /api/notion/connect 会返回错误提示")
server = HTTPServer(("0.0.0.0", PORT), NotionMCPHandler)
print(f"\n 🔗 Notion MCP Server 已启动 → 0.0.0.0:{PORT}")
print(f" 📝 回调地址: {REDIRECT_URI}")
print(f" 💾 数据目录: {DATA_DIR}\n")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n 关闭服务器...")
server.server_close()