[eric] notion integration

This commit is contained in:
ciregenz
2026-04-02 16:40:36 -07:00
parent 57465a919f
commit cac9872702
8 changed files with 2306 additions and 72 deletions
+3
View File
@@ -17,6 +17,9 @@ electron/package-lock.json
9router/.next/
9router/package-lock.json
# Pre-installed MCP server dependencies
backend/npm-servers/*/node_modules/
# Frontend build output
frontend/dist/
# Bundled uv binaries (downloaded during build)
+1 -1
View File
@@ -1593,7 +1593,7 @@ class AgentManager:
resp = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=30,
system="Generate a clear 3-5 word title for this chat. Use plain language like 'Debug Login Page', 'Weekly Report Draft', 'API Integration Help'. No quotes, no punctuation, no emojis. Return only the title.",
system="Generate a clear 3-5 word title describing what the user wants to do. Examples: 'Summarize Today Emails', 'Debug Login Page', 'Weekly Report Draft'. Never describe errors, outcomes, or system states. No quotes, no punctuation, no emojis. Return only the title.",
messages=[{"role": "user", "content": first_prompt}],
)
generated = resp.content[0].text.strip().strip('"\'')
+50 -2
View File
@@ -155,6 +155,29 @@ BROWSER_TOOLS_SCHEMA = [
"required": [],
},
},
{
"name": "RequestHumanIntervention",
"description": (
"Request the user's help when you encounter an obstacle you cannot solve "
"programmatically — captchas, login prompts, cookie consent walls, "
"two-factor authentication, or any blocking popup. The agent will pause "
"until the user resolves the issue and clicks Continue."
),
"input_schema": {
"type": "object",
"properties": {
"problem": {
"type": "string",
"description": "What obstacle was encountered.",
},
"instruction": {
"type": "string",
"description": "What the user should do to resolve it.",
},
},
"required": ["problem", "instruction"],
},
},
]
ACTION_MAP = {
@@ -188,8 +211,9 @@ SYSTEM_PROMPT = (
"you've reached the end of the page.\n"
"- For complex SPAs (Notion, Gmail, etc.), prefer BrowserScroll over BrowserEvaluate for scrolling.\n"
"- Avoid looping: if scrolling shows no new content (scrolled 0px), you're at the boundary.\n\n"
"You have access ONLY to browser tools. Do not ask the user questions — "
"complete the task autonomously to the best of your ability."
"You have access ONLY to browser tools. Complete the task autonomously. "
"If you encounter a captcha, login wall, or popup you cannot bypass, "
"use RequestHumanIntervention to ask the user for help instead of retrying endlessly."
)
MAX_TURNS = 25
@@ -427,6 +451,30 @@ async def run_browser_agent(
cancelled = True
break
# Handle RequestHumanIntervention — pause and wait for user
if tu.name == "RequestHumanIntervention":
problem = tu.input.get("problem", "")
instruction = tu.input.get("instruction", "")
decision = await _request_browser_approval(
session, tu.name, {"problem": problem, "instruction": instruction},
)
result_text = "User resolved the issue." if decision.get("behavior") != "deny" else "User declined to help."
tool_results.append({
"type": "tool_result",
"tool_use_id": tu.id,
"content": [{"type": "text", "text": result_text}],
})
result_msg = Message(
role="tool_result",
content={"text": result_text, "tool_name": tu.name, "elapsed_ms": 0},
)
session.messages.append(result_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": result_msg.model_dump(mode="json"),
})
continue
policy = _browser_perms.get(tu.name, "always_allow")
if policy == "deny":
+127 -63
View File
@@ -127,44 +127,72 @@ async def oauth_callback(code: str = Query(...), state: str = Query("")):
return HTMLResponse("<html><body><h2>Invalid OAuth state</h2></body></html>", status_code=400)
tool = _load(tool_id)
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
_port = os.environ.get("OPENSWARM_PORT", "8324")
redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback"
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(GOOGLE_TOKEN_URL, data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
})
if tool.name.lower() == "notion":
# Notion OAuth: Basic auth with client_id:secret
notion_client_id = os.environ.get("NOTION_OAUTH_CLIENT_ID", "")
notion_client_secret = os.environ.get("NOTION_OAUTH_CLIENT_SECRET", "")
import base64
credentials = base64.b64encode(f"{notion_client_id}:{notion_client_secret}".encode()).decode()
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post("https://api.notion.com/v1/oauth/token", json={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
}, headers={
"Authorization": f"Basic {credentials}",
"Content-Type": "application/json",
})
if resp.status_code != 200:
logger.warning(f"OAuth token exchange failed: {resp.text}")
return HTMLResponse(f"<html><body><h2>Token exchange failed</h2><pre>{resp.text}</pre></body></html>", status_code=400)
if resp.status_code != 200:
logger.warning(f"Notion OAuth token exchange failed: {resp.text}")
return HTMLResponse(f"<html><body><h2>Token exchange failed</h2><pre>{resp.text}</pre></body></html>", status_code=400)
tokens = resp.json()
access_token = tokens.get("access_token", "")
tool.oauth_tokens = {
"access_token": access_token,
"refresh_token": tokens.get("refresh_token", ""),
"token_expiry": time.time() + tokens.get("expires_in", 3600),
}
tool.auth_status = "connected"
tokens = resp.json()
tool.oauth_tokens = {
"access_token": tokens.get("access_token", ""),
}
tool.auth_status = "connected"
tool.connected_account_email = tokens.get("workspace_name", "Notion workspace")
else:
# Google OAuth
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(GOOGLE_TOKEN_URL, data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
})
if access_token:
try:
async with httpx.AsyncClient(timeout=10.0) as info_client:
info_resp = await info_client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
if info_resp.status_code == 200:
tool.connected_account_email = info_resp.json().get("email")
except Exception as e:
logger.warning(f"Failed to fetch Google userinfo: {e}")
if resp.status_code != 200:
logger.warning(f"OAuth token exchange failed: {resp.text}")
return HTMLResponse(f"<html><body><h2>Token exchange failed</h2><pre>{resp.text}</pre></body></html>", status_code=400)
tokens = resp.json()
access_token = tokens.get("access_token", "")
tool.oauth_tokens = {
"access_token": access_token,
"refresh_token": tokens.get("refresh_token", ""),
"token_expiry": time.time() + tokens.get("expires_in", 3600),
}
tool.auth_status = "connected"
if access_token:
try:
async with httpx.AsyncClient(timeout=10.0) as info_client:
info_resp = await info_client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
if info_resp.status_code == 200:
tool.connected_account_email = info_resp.json().get("email")
except Exception as e:
logger.warning(f"Failed to fetch Google userinfo: {e}")
_save(tool)
@@ -391,6 +419,9 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
else:
env = config.setdefault("env", {})
env["OAUTH_ACCESS_TOKEN"] = tool.oauth_tokens["access_token"]
# Notion MCP uses NOTION_TOKEN env var
if tool.name.lower() == "notion":
env["NOTION_TOKEN"] = tool.oauth_tokens["access_token"]
if tool.oauth_tokens.get("refresh_token"):
env["GOOGLE_WORKSPACE_REFRESH_TOKEN"] = tool.oauth_tokens["refresh_token"]
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
@@ -407,13 +438,29 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
pkg_name = next((a for a in (config.get("args") or []) if not a.startswith("-")), None)
if pkg_name:
_backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
bundle_path = os.path.join(_backend, "mcp-bundles", f"{pkg_name}.js")
electron_path = os.environ.get("OPENSWARM_ELECTRON_PATH")
# Check for single-file bundle first (e.g. reddit-mcp-buddy)
bundle_path = os.path.join(_backend, "mcp-bundles", f"{pkg_name}.js")
if os.path.isfile(bundle_path) and electron_path:
config["command"] = electron_path
config["args"] = [bundle_path]
config.setdefault("env", {})["ELECTRON_RUN_AS_NODE"] = "1"
logger.info(f"Using bundled MCP server for {pkg_name}")
elif electron_path:
# Check for pre-installed npm package
safe_dir = pkg_name.replace("/", "-").replace("@", "")
npm_dir = os.path.join(_backend, "npm-servers", safe_dir)
pkg_json_path = os.path.join(npm_dir, "node_modules", pkg_name, "package.json")
if os.path.isfile(pkg_json_path):
import json as _json
with open(pkg_json_path) as f:
pkg_meta = _json.load(f)
bin_field = pkg_meta.get("bin", {})
entry = list(bin_field.values())[0] if isinstance(bin_field, dict) else bin_field
config["command"] = electron_path
config["args"] = [os.path.join(npm_dir, "node_modules", pkg_name, entry)]
config.setdefault("env", {})["ELECTRON_RUN_AS_NODE"] = "1"
logger.info(f"Using pre-installed npm MCP server for {pkg_name}")
if not os.path.isabs(config.get("command", "")):
resolved = _resolve_command(config["command"])
@@ -611,6 +658,7 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=proc_env,
limit=10 * 1024 * 1024, # 10 MB buffer for large tool lists
)
async def _send(msg: dict) -> None:
@@ -685,22 +733,26 @@ async def discover_tools(tool_id: str):
tool = _load(tool_id)
if tool.auth_type == "oauth2" and tool.auth_status == "connected":
refreshed = await refresh_google_token(tool)
if not refreshed and tool.oauth_tokens.get("access_token"):
expiry = tool.oauth_tokens.get("token_expiry", 0)
if time.time() >= expiry - 60:
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
if not client_id:
# Only attempt Google token refresh for tools that use Google OAuth
# (identified by having a refresh_token — Notion and other non-Google
# OAuth providers don't use refresh tokens through our flow).
if tool.oauth_tokens.get("refresh_token"):
refreshed = await refresh_google_token(tool)
if not refreshed and tool.oauth_tokens.get("access_token"):
expiry = tool.oauth_tokens.get("token_expiry", 0)
if time.time() >= expiry - 60:
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
if not client_id:
raise HTTPException(
status_code=400,
detail="OAuth token expired and GOOGLE_OAUTH_CLIENT_ID is not set. "
"In the packaged app, create ~/.openswarm.env or "
"~/Library/Application Support/OpenSwarm/.env with your Google OAuth credentials.",
)
raise HTTPException(
status_code=400,
detail="OAuth token expired and GOOGLE_OAUTH_CLIENT_ID is not set. "
"In the packaged app, create ~/.openswarm.env or "
"~/Library/Application Support/OpenSwarm/.env with your Google OAuth credentials.",
status_code=502,
detail="OAuth token expired and refresh failed. Try reconnecting Google.",
)
raise HTTPException(
status_code=502,
detail="OAuth token expired and refresh failed. Try reconnecting Google.",
)
config = derive_mcp_config(tool)
if not config:
@@ -798,27 +850,39 @@ async def oauth_disconnect(tool_id: str):
@tools_lib.router.post("/{tool_id}/oauth/start")
async def oauth_start(tool_id: str):
_load(tool_id)
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
if not client_id:
raise HTTPException(status_code=400, detail="GOOGLE_OAUTH_CLIENT_ID not set in backend .env")
tool = _load(tool_id)
_port = os.environ.get("OPENSWARM_PORT", "8324")
redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback"
state = tool_id
_pending_oauth[state] = tool_id
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(GOOGLE_SCOPES),
"access_type": "offline",
"prompt": "consent",
"state": state,
}
auth_url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
if tool.name.lower() == "notion":
client_id = os.environ.get("NOTION_OAUTH_CLIENT_ID", "")
if not client_id:
raise HTTPException(status_code=400, detail="NOTION_OAUTH_CLIENT_ID not set in backend .env")
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"owner": "user",
"state": state,
}
auth_url = f"https://api.notion.com/v1/oauth/authorize?{urlencode(params)}"
else:
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
if not client_id:
raise HTTPException(status_code=400, detail="GOOGLE_OAUTH_CLIENT_ID not set in backend .env")
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(GOOGLE_SCOPES),
"access_type": "offline",
"prompt": "consent",
"state": state,
}
auth_url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
return {"auth_url": auth_url}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
{
"name": "notion-mcp-server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@notionhq/notion-mcp-server": "^2.2.1"
}
}
+17 -3
View File
@@ -157,6 +157,20 @@ const INTEGRATIONS: Integration[] = [
),
authType: 'oauth2',
},
{
id: 'notion',
name: 'Notion',
description: 'Create, read, and update pages, databases, and blocks in Notion workspaces.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@notionhq/notion-mcp-server'] },
color: '#000000',
website: 'https://www.notion.so',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<path d="M4.46 2.95c.53.43.73.4 1.73.33l9.4-.57c.2 0 .03-.2-.03-.23l-1.57-1.13c-.3-.23-.7-.5-1.47-.43L3.3 1.78c-.5.07-.6.33-.4.53l1.56 1.13v.51zM5.03 6.15v9.93c0 .53.27.73.87.7l10.33-.6c.6-.03.67-.4.67-.83V5.52c0-.43-.17-.63-.53-.6l-10.8.63c-.4.03-.54.2-.54.6zM14.93 6.63c.07.3 0 .6-.3.63l-.5.1v7.33c-.43.23-.83.37-1.17.37-.53 0-.67-.17-1.07-.67l-3.27-5.13v4.97l1.03.23s0 .6-.83.6l-2.3.13c-.07-.13 0-.47.23-.53l.6-.17V8.33l-.83-.07c-.07-.3.1-.73.57-.77l2.47-.17 3.4 5.2V8.6l-.87-.1c-.07-.37.2-.63.53-.67l2.3-.2zM2.57 1.28L11.93.28c1.17-.1 1.47-.03 2.2.5l3.03 2.13c.5.37.67.47.67.87v15.27c0 .63-.23 1-.87 1.07l-10.7.63c-.5.03-.73-.07-.97-.37L2.73 17.3c-.27-.33-.4-.6-.4-1.03V2.15c0-.53.23-.83.83-.87h-.59z" fill="#000"/>
</svg>
),
authType: 'oauth2',
},
];
const CATEGORY_ORDER = ['filesystem', 'system', 'search', 'interaction', 'agents', 'planning', 'scheduling'];
@@ -773,11 +787,11 @@ const Tools: React.FC = () => {
const afterConnect = async () => {
const statusResult = await dispatch(fetchToolStatus(toolId));
if (fetchToolStatus.fulfilled.match(statusResult) && statusResult.payload.auth_status === 'connected') {
setSnackbar({ open: true, message: 'Google account connected! Discovering actions…' });
setSnackbar({ open: true, message: 'Account connected! Discovering actions…' });
setExpandedToolId(toolId);
dispatch(discoverTools(toolId));
} else {
setSnackbar({ open: true, message: 'Google account connected!' });
setSnackbar({ open: true, message: 'Account connected!' });
}
};
@@ -1425,7 +1439,7 @@ const Tools: React.FC = () => {
onClick={(e) => { e.stopPropagation(); handleOAuthConnect(tool.id); }}
sx={{ borderColor: `${c.status.info}40`, color: c.status.info, '&:hover': { borderColor: c.status.info, bgcolor: `${c.status.info}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0 }}
>
Connect Google
Connect {tool.name}
</Button>
)}
{!isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && (
+29 -3
View File
@@ -70,15 +70,41 @@ echo ""
MCP_BUNDLE_DIR="$PROJECT_ROOT/backend/mcp-bundles"
mkdir -p "$MCP_BUNDLE_DIR"
if [[ ! -f "$MCP_BUNDLE_DIR/reddit-mcp-buddy.js" ]]; then
echo "[0b] Bundling npm MCP servers..."
echo "[0b] Bundling reddit-mcp-buddy..."
TMPDIR_MCP=$(mktemp -d)
cd "$TMPDIR_MCP"
npm install reddit-mcp-buddy --silent 2>/dev/null
npx esbuild node_modules/reddit-mcp-buddy/dist/index.js --bundle --platform=node --format=cjs --outfile="$MCP_BUNDLE_DIR/reddit-mcp-buddy.js" 2>/dev/null
rm -rf "$TMPDIR_MCP"
echo "npm MCP servers bundled."
echo "reddit-mcp-buddy bundled."
else
echo "[0b] npm MCP bundles already present."
echo "[0b] reddit-mcp-buddy bundle already present."
fi
# Step 0c: Pre-install npm MCP servers that can't be esbuild'd
NPM_SERVERS_DIR="$PROJECT_ROOT/backend/npm-servers"
mkdir -p "$NPM_SERVERS_DIR"
if [[ ! -d "$NPM_SERVERS_DIR/softeria-ms-365-mcp-server/node_modules" ]]; then
echo "[0c] Installing @softeria/ms-365-mcp-server..."
mkdir -p "$NPM_SERVERS_DIR/softeria-ms-365-mcp-server"
cd "$NPM_SERVERS_DIR/softeria-ms-365-mcp-server"
npm init -y > /dev/null 2>&1
npm install @softeria/ms-365-mcp-server --silent 2>/dev/null
echo "@softeria/ms-365-mcp-server installed."
else
echo "[0c] @softeria/ms-365-mcp-server already present."
fi
if [[ ! -d "$NPM_SERVERS_DIR/notionhq-notion-mcp-server/node_modules" ]]; then
echo "[0c] Installing @notionhq/notion-mcp-server..."
mkdir -p "$NPM_SERVERS_DIR/notionhq-notion-mcp-server"
cd "$NPM_SERVERS_DIR/notionhq-notion-mcp-server"
npm init -y > /dev/null 2>&1
npm install @notionhq/notion-mcp-server --silent 2>/dev/null
echo "@notionhq/notion-mcp-server installed."
else
echo "[0c] @notionhq/notion-mcp-server already present."
fi
echo ""