添加:Notion MCP Server + 首页连接入口

This commit is contained in:
铸渊 2026-05-19 18:48:53 +08:00
parent a1b817d098
commit 4db1714635
2 changed files with 791 additions and 4 deletions

View File

@ -161,6 +161,12 @@ body{
/* ── Notion预留 ── */
.notion-placeholder{text-align:center;padding:12px;font-size:12px;color:var(--text-faint);border:1px dashed var(--glass-edge);border-radius:12px;background:rgba(255,255,255,.01)}
.notion-connect-btn{display:inline-flex;align-items:center;gap:8px;padding:8px 18px;margin-top:10px;background:linear-gradient(135deg,rgba(108,76,175,.15),rgba(42,139,165,.1));border:1px solid var(--glass-edge);border-radius:10px;color:var(--silver);font-size:13px;cursor:pointer;transition:all .3s;font-family:inherit}
.notion-connect-btn:hover{background:linear-gradient(135deg,rgba(108,76,175,.3),rgba(42,139,165,.2));border-color:rgba(78,200,216,.2);transform:translateY(-1px)}
.notion-connect-btn.connected{background:rgba(56,168,106,.12);border-color:rgba(56,168,106,.2);color:var(--green)}
.notion-connect-btn .nb-led{width:6px;height:6px;border-radius:50%;background:var(--text-faint);flex-shrink:0;animation:led-soft 2s ease-in-out infinite}
.notion-connect-btn.connected .nb-led{background:var(--green);box-shadow:0 0 8px var(--green)}
.notion-user-item{display:flex;align-items:center;justify-content:space-between;padding:6px 0;font-size:13px;border-bottom:1px solid var(--glass-edge)}
/* ── Footer ── */
.footer{text-align:center;padding:20px;font-size:12px;color:var(--text-faint);letter-spacing:.12em;border-top:1px solid var(--glass-edge);margin-top:auto;position:relative;z-index:1}
@ -307,11 +313,25 @@ body{
</div>
<div class="card">
<div class="card-title"><span style="width:6px;height:6px;border-radius:50%;background:var(--violet-glow);box-shadow:0 0 8px var(--violet-glow)"></span>Notion · 开发进度</div>
<div class="notion-placeholder">
⬡ 等待 Notion 接入<br>
<span style="font-size:11px;opacity:.7">后续将展示每位成员的实时开发进度</span>
<div class="card-title" style="justify-content:space-between">
<span style="display:flex;align-items:center;gap:8px">
<span style="width:6px;height:6px;border-radius:50%;background:var(--violet-glow);box-shadow:0 0 8px var(--violet-glow)"></span>
Notion · 开发进度
</span>
<span style="font-size:11px;color:var(--text-faint);font-weight:400" id="notion-status-label">未连接</span>
</div>
<div id="notion-status-area">
<div class="notion-placeholder">
⬡ 连接 Notion 后,铸渊可读取和记录你的页面<br>
<button class="notion-connect-btn" onclick="connectNotion()">
<span class="nb-led"></span> 连接 Notion
</button>
<div style="margin-top:8px;font-size:11px;opacity:.6">
权限:✅ 按语言路径读取 · ✅ 新建页面 · ❌ 不编辑/删除现有
</div>
</div>
</div>
<div id="notion-users" style="display:none;margin-top:8px"></div>
</div>
</div>
</div>
@ -397,6 +417,38 @@ function login(){const u=document.getElementById('login-user').value.trim();cons
async function updateTraining(){try{const r=await fetch('/training-status.json');if(!r.ok)return;const d=await r.json();if(d.step===undefined)return;const s=d.step||0,t=d.total||12243,p=Math.min(s/t*100,100);document.getElementById('train-step').textContent=s.toLocaleString()+' / '+t.toLocaleString();document.getElementById('train-pct').textContent=p.toFixed(1)+'%';document.getElementById('train-bar').style.width=p+'%';if(d.loss!==undefined)document.getElementById('train-loss').textContent=d.loss;const eta=d.eta||'训练中';document.getElementById('train-eta').textContent='预计 '+eta+' 完成 · Step '+s.toLocaleString();const status=document.getElementById('persona-zhuyuan-status');if(status)status.textContent='训练中 · '+p.toFixed(0)+'%';const tag=document.getElementById('status-tag');if(tag)tag.textContent='● 训练中 · Step '+s.toLocaleString()}catch(e){}}
setInterval(updateTraining,30000);updateTraining();
/* ── Notion 连接 ── */
async function connectNotion(){try{window.location.href='/api/notion/connect?user_key=default'}catch(e){alert('Notion 连接暂时不可用')}}
async function checkNotionStatus(){
try{
const r=await fetch('/api/notion/status');
const d=await r.json();
const area=document.getElementById('notion-status-area');
const users=document.getElementById('notion-users');
const label=document.getElementById('notion-status-label');
if(!d.ok)return;
if(d.connected_users&&d.connected_users.length>0){
const u=d.connected_users[0];
label.textContent='✅ 已连接 · '+u.workspace;
document.getElementById('notion-status-area').querySelector('.notion-placeholder').innerHTML=
'<div style="font-size:13px;color:var(--green)">✅ Notion 已连接</div>'+
'<div style="margin-top:4px;font-size:12px;color:var(--text-soft)">工作区: '+u.workspace+'</div>'+
'<button class="notion-connect-btn connected" onclick="disconnectNotion()">'+
'<span class="nb-led"></span> 断开连接</button>';
}
if(d.connected_users&&d.connected_users.length>1){
users.style.display='block';
users.innerHTML='<div style="font-size:12px;color:var(--text-faint);margin-top:6px">已连接用户:</div>'+
d.connected_users.slice(1).map(u=>
'<div class="notion-user-item"><span>'+u.user+'</span><span style="color:var(--text-faint);font-size:12px">'+u.workspace+'</span></div>'
).join('');
}
}catch(e){}
}
async function disconnectNotion(){try{const r=await fetch('/api/notion/disconnect?user_key=default');const d=await r.json();if(d.ok)location.reload()}catch(e){}}
setInterval(checkNotionStatus,15000);checkNotionStatus();
/* ── Chat ── */
let chatOpen=false;
function toggleChat(){chatOpen=!chatOpen;document.getElementById('chat-panel').classList.toggle('open')}

735
notion/notion-mcp-server.py Normal file
View File

@ -0,0 +1,735 @@
#!/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) if user else 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) if user else 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) if user else 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) if user else 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()