"""
Command-line interface for NotebookLM MCP Server
"""
import asyncio
import json
import re
import sys
from pathlib import Path
from typing import Optional
import click
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from .client import NotebookLMClient
from .config import AuthConfig, ServerConfig, load_config
from .exceptions import ConfigurationError
from .server import NotebookLMFastMCP
console = Console()
def extract_notebook_id(url: str) -> str:
"""Extract notebook ID from NotebookLM URL"""
# Pattern to match NotebookLM URL with notebook ID
patterns = [
r"https://notebooklm\.google\.com/notebook/([a-f0-9-]{36})",
r"notebooklm\.google\.com/notebook/([a-f0-9-]{36})",
r"([a-f0-9-]{36})", # Just the ID itself
]
for pattern in patterns:
match = re.search(pattern, url)
if match:
return match.group(1)
raise ValueError(f"Invalid NotebookLM URL or ID: {url}")
def create_default_config(
notebook_id: str, config_path: str = "notebooklm-config.json"
) -> None:
"""Create default configuration file"""
config = {
"headless": False,
"debug": False,
"timeout": 60,
"default_notebook_id": notebook_id,
"base_url": "https://notebooklm.google.com",
"server_name": "notebooklm-mcp",
"stdio_mode": True,
"streaming_timeout": 60,
"response_stability_checks": 3,
"retry_attempts": 3,
"auth": {
"cookies_path": None,
"profile_dir": "./chrome_profile_notebooklm",
"use_persistent_session": True,
"auto_login": True,
},
}
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
console.print(f"✅ Created config file: [bold green]{config_path}[/bold green]")
def update_config_to_headless(config_path: str = "notebooklm-config.json") -> None:
"""Update config file to set headless=true after successful setup"""
try:
# Read current config
with open(config_path, "r") as f:
config = json.load(f)
# Update headless setting
config["headless"] = True
# Write back to file
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
console.print(
"✅ Updated config: [bold yellow]headless=true[/bold yellow] for optimal performance"
)
except Exception as e:
console.print(f"⚠️ Failed to update config to headless mode: {e}")
@click.group()
@click.version_option()
@click.option(
"--config", "-c", type=click.Path(exists=True), help="Configuration file path"
)
@click.option("--debug", is_flag=True, help="Enable debug logging")
@click.pass_context
def cli(ctx: click.Context, config: Optional[str], debug: bool) -> None:
"""NotebookLM MCP Server - Professional automation for Google NotebookLM"""
ctx.ensure_object(dict)
try:
server_config = load_config(config)
if debug:
server_config.debug = True
ctx.obj["config"] = server_config
ctx.obj["config_file"] = (
config or "notebooklm-config.json"
) # Store config file path
except Exception as e:
console.print(f"[red]Configuration error: {e}[/red]")
sys.exit(1)
@cli.command()
@click.argument("notebook_url")
@click.option(
"--config-path",
"-o",
default="notebooklm-config.json",
help="Output config file path",
)
@click.option("--headless", is_flag=True, help="Run initial setup in headless mode")
def init(notebook_url: str, config_path: str, headless: bool) -> None:
"""Initialize NotebookLM MCP Server with notebook URL
NOTEBOOK_URL: NotebookLM notebook URL or ID
Examples:
notebooklm-mcp init https://notebooklm.google.com/notebook/4741957b-f358-48fb-a16a-da8d20797bc6
notebooklm-mcp init 4741957b-f358-48fb-a16a-da8d20797bc6
"""
try:
# Extract notebook ID from URL
notebook_id = extract_notebook_id(notebook_url)
console.print(
Panel.fit(
f"[bold blue]🚀 Initializing NotebookLM MCP Server[/bold blue]\n"
f"Notebook ID: [green]{notebook_id}[/green]\n"
f"Config File: [yellow]{config_path}[/yellow]",
title="Setup Starting",
)
)
# Create config file
create_default_config(notebook_id, config_path)
# Create profile directory
profile_dir = Path("./chrome_profile_notebooklm")
profile_dir.mkdir(exist_ok=True)
console.print(
f"✅ Created profile directory: [bold green]{profile_dir}[/bold green]"
)
# Guided setup
console.print("\n[bold yellow]🔧 Setting up browser profile...[/bold yellow]")
# Create temporary config for guided setup
temp_config = ServerConfig(
default_notebook_id=notebook_id,
headless=headless,
debug=False,
auth=AuthConfig(
profile_dir=str(profile_dir),
use_persistent_session=True,
auto_login=True,
),
)
# Run guided setup
setup_success = asyncio.run(guided_setup(temp_config))
# Update config to headless if setup was successful
if setup_success:
update_config_to_headless(config_path)
console.print(
"\n[bold blue]🚀 Optimization:[/bold blue] Config updated to [yellow]headless=true[/yellow] for better performance"
)
console.print(
Panel.fit(
"[bold green]✅ Setup Complete![/bold green]\n\n"
f"Config file: [yellow]{config_path}[/yellow]\n"
f"Profile directory: [yellow]{profile_dir}[/yellow]\n"
f"Headless mode: [yellow]{'✅ Enabled' if setup_success else '❌ Disabled'}[/yellow]\n\n"
"[bold blue]Next steps:[/bold blue]\n"
f"notebooklm-mcp --config {config_path} server",
title="🎉 Ready to Use",
)
)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
sys.exit(1)
except Exception as e:
console.print(f"[red]Setup failed: {e}[/red]")
sys.exit(1)
@cli.command()
@click.option("--notebook", "-n", help="Notebook ID to use")
@click.option("--headless", is_flag=True, help="Run in headless mode")
@click.option("--port", type=int, default=8000, help="Server port (default: 8000)")
@click.option("--host", default="127.0.0.1", help="Server host (default: 127.0.0.1)")
@click.option(
"--root-dir",
help="Root directory for server operations (default: current directory)",
)
@click.option(
"--transport",
type=click.Choice(["stdio", "http", "sse"]),
default="stdio",
help="Transport protocol (default: stdio)",
)
@click.pass_context
def server(
ctx: click.Context,
notebook: Optional[str],
headless: bool,
port: int,
host: str,
root_dir: Optional[str],
transport: str,
) -> None:
"""Start the FastMCP v2 NotebookLM server"""
import os
from pathlib import Path
config: ServerConfig = ctx.obj["config"]
# Auto-detect current working directory as root
if root_dir:
working_dir = Path(root_dir).resolve()
else:
working_dir = Path.cwd()
# Ensure root directory exists
if not working_dir.exists():
console.print(f"[red]Root directory does not exist: {working_dir}[/red]")
sys.exit(1)
if notebook:
config.default_notebook_id = notebook
if headless:
config.headless = True
console.print(
Panel.fit(
"[bold blue]Starting NotebookLM FastMCP v2 Server[/bold blue]\n"
f"Mode: {'Headless' if config.headless else 'GUI'}\n"
f"Transport: {transport.upper()}\n"
f"{'Host: ' + host if transport != 'stdio' else ''}\n"
f"{'Port: ' + str(port) if transport != 'stdio' else ''}\n"
f"Notebook: {config.default_notebook_id or 'None'}\n"
f"Working Directory: {working_dir}\n"
f"Profile: {config.auth.profile_dir}\n"
f"Debug: {config.debug}",
title="🚀 FastMCP Server Starting",
)
)
# Change to working directory
os.chdir(working_dir)
console.print(f"[dim]📁 Set working directory to: {working_dir}[/dim]")
try:
# Use FastMCP v2 implementation only
server = NotebookLMFastMCP(config)
if transport == "http":
console.print(
f"[green]🌐 FastMCP HTTP server will be available at: http://{host}:{port}/mcp/[/green]"
)
elif transport == "sse":
console.print(
f"[green]🌐 FastMCP SSE server will be available at: http://{host}:{port}/[/green]"
)
asyncio.run(server.start(transport=transport, host=host, port=port))
except KeyboardInterrupt:
console.print("\n[yellow]Server stopped by user[/yellow]")
except Exception as e:
console.print(f"[red]Server error: {e}[/red]")
# Better authentication error handling
if "Authentication required" in str(e):
console.print(
Panel.fit(
"[yellow]🔐 Authentication Required[/yellow]\n\n"
"The server needs manual authentication to access NotebookLM.\n\n"
"[bold]To fix this:[/bold]\n"
"1. Run without --headless flag for manual login:\n"
f" [cyan]notebooklm-mcp --config {ctx.obj.get('config_file', 'notebooklm-config.json')} server[/cyan]\n\n"
"2. Complete Google login in the browser\n"
"3. Then retry with --headless flag for production use",
title="🔑 Authentication Help",
)
)
if config.debug:
import traceback
console.print(traceback.format_exc())
sys.exit(1)
@cli.command()
@click.option("--notebook", "-n", help="Notebook ID to use")
@click.option("--message", "-m", help="Message to send")
@click.option("--headless", is_flag=True, help="Run in headless mode")
@click.pass_context
def chat(
ctx: click.Context, notebook: Optional[str], message: Optional[str], headless: bool
) -> None:
"""Interactive chat with NotebookLM"""
config: ServerConfig = ctx.obj["config"]
if notebook:
config.default_notebook_id = notebook
if headless:
config.headless = True
async def run_chat() -> None:
client = NotebookLMClient(config)
try:
console.print("[yellow]Starting browser...[/yellow]")
await client.start()
console.print("[yellow]Authenticating...[/yellow]")
auth_success = await client.authenticate()
if not auth_success:
console.print(
"[red]Authentication failed. Please login manually in browser.[/red]"
)
if not config.headless:
console.print("[blue]Press Enter when logged in...[/blue]")
input()
if message:
# Single message mode
console.print(f"[blue]Sending: {message}[/blue]")
await client.send_message(message)
console.print("[yellow]Waiting for response...[/yellow]")
response = await client.get_response()
console.print(Panel(response, title="🤖 NotebookLM Response"))
else:
# Interactive mode
console.print(
"[green]Interactive mode started. Type 'quit' to exit.[/green]"
)
while True:
try:
user_message = console.input("\n[bold blue]You:[/bold blue] ")
if user_message.lower() in ["quit", "exit", "q"]:
break
await client.send_message(user_message)
console.print("[yellow]Waiting for response...[/yellow]")
response = await client.get_response()
console.print(
f"[bold green]NotebookLM:[/bold green] {response}"
)
except KeyboardInterrupt:
break
except Exception as e:
console.print(f"[red]Chat error: {e}[/red]")
finally:
await client.close()
try:
asyncio.run(run_chat())
except Exception as e:
console.print(f"[red]Chat session error: {e}[/red]")
sys.exit(1)
@cli.command()
@click.option("--config", "-c", required=True, help="Configuration file path")
@click.option("--notebook", "-n", required=True, help="Notebook ID")
@click.option("--profile", "-p", help="Path to existing Chrome profile to import")
@click.option("--headless", is_flag=True, help="Run in headless mode")
@click.option(
"--setup-only", is_flag=True, help="Only create config, don't run browser"
)
@click.pass_context
def quick_setup(
ctx: click.Context,
config: str,
notebook: str,
profile: Optional[str],
headless: bool,
setup_only: bool,
) -> None:
"""Quick setup with config file and optional profile import"""
async def run_setup():
try:
# Step 1: Create enhanced config
console.print("📋 Step 1: Creating configuration...")
server_config = ServerConfig(
default_notebook_id=notebook,
headless=headless,
auth=AuthConfig(
import_profile_from=profile,
skip_manual_login=bool(
profile
), # Skip manual login if profile provided
),
)
# Step 2: Setup profile
console.print("🔧 Step 2: Setting up Chrome profile...")
server_config.setup_profile()
# Step 3: Save config
console.print("💾 Step 3: Saving configuration...")
server_config.save_to_file(config)
console.print(
Panel.fit(
f"[bold green]✅ Configuration Complete![/bold green]\n\n"
f"📁 Config saved to: {config}\n"
f"📝 Notebook ID: {notebook}\n"
f"🔧 Profile: {'Imported' if profile else 'New'}\n"
f"👀 Mode: {'Headless' if headless else 'GUI'}",
title="📋 Config Ready",
)
)
# Step 4: Initialize and test browser (unless setup-only)
if not setup_only:
console.print("\n🌐 Step 4: Testing browser connection...")
# Import client here to avoid circular imports
from .client import NotebookLMClient
# Create client
client = NotebookLMClient(server_config)
try:
# Start browser
console.print("🔄 Starting browser...")
await client.start()
console.print("✅ Browser started successfully!")
# Test authentication
console.print("🔐 Testing authentication...")
auth_success = await client.authenticate()
if auth_success:
console.print(
"✅ Authentication successful - no manual login needed!"
)
console.print(
Panel.fit(
f"[bold green]🎉 Complete Setup Success![/bold green]\n\n"
f"Your NotebookLM MCP server is ready to use!\n\n"
f"[yellow]Start server:[/yellow]\n"
f"notebooklm-mcp server -c {config}\n\n"
f"[yellow]Start interactive chat:[/yellow]\n"
f"notebooklm-mcp chat -c {config}",
title="🚀 Ready to Use!",
)
)
else:
console.print("⚠️ Manual login required")
console.print(
Panel.fit(
"[yellow]Manual Login Needed[/yellow]\n\n"
"Browser is open for you to login manually.\n"
"1. Complete Google login in the browser\n"
"2. Navigate to your notebook\n"
"3. Press Enter when ready...",
title="🔐 Login Required",
)
)
# Wait for user to complete login
input("\nPress Enter after completing login...")
# Test again
auth_success = await client.authenticate()
if auth_success:
console.print(
"✅ Login successful! Session saved for future use."
)
else:
console.print(
"⚠️ Authentication still pending - you can try again later"
)
console.print(
Panel.fit(
f"[bold green]✅ Setup Complete![/bold green]\n\n"
f"[yellow]Your session is now saved![/yellow]\n"
f"Future runs will auto-authenticate.\n\n"
f"[yellow]Start server:[/yellow]\n"
f"notebooklm-mcp server -c {config}",
title="🎉 Setup Complete!",
)
)
except Exception as e:
console.print(f"⚠️ Browser test failed: {e}")
console.print(
"Config created successfully, but browser needs manual setup"
)
finally:
# Clean up
try:
await client.close()
console.print("🔄 Browser closed")
except Exception:
pass
else:
# Setup-only mode
console.print(
Panel.fit(
f"[bold green]✅ Config-Only Setup Complete![/bold green]\n\n"
f"📁 Configuration saved to: {config}\n\n"
f"[yellow]Next steps:[/yellow]\n"
f"• notebooklm-mcp server -c {config}\n"
f"• notebooklm-mcp chat -c {config}",
title="📋 Config Ready",
)
)
except Exception as e:
console.print(f"[red]Setup failed: {e}[/red]")
import traceback
console.print(f"[red]Details: {traceback.format_exc()}[/red]")
sys.exit(1)
# Run async setup
import asyncio
asyncio.run(run_setup())
@cli.command()
@click.option("--from-profile", "-f", required=True, help="Source Chrome profile path")
@click.option("--to-profile", "-t", required=True, help="Destination profile path")
@click.pass_context
def import_profile(ctx: click.Context, from_profile: str, to_profile: str) -> None:
"""Import existing Chrome profile"""
try:
from pathlib import Path
from shutil import copytree, rmtree
source = Path(from_profile)
dest = Path(to_profile)
if not source.exists():
raise ConfigurationError(f"Source profile not found: {source}")
if dest.exists():
console.print(f"[yellow]Removing existing profile: {dest}[/yellow]")
rmtree(dest)
copytree(source, dest)
console.print(
Panel.fit(
f"[bold green]✅ Profile Import Complete![/bold green]\n\n"
f"📁 From: {source}\n"
f"📁 To: {dest}\n\n"
f"[yellow]You can now use this profile in your config:[/yellow]\n"
f' "auth": {{\n'
f' "profile_dir": "{dest}",\n'
f' "use_persistent_session": true\n'
f" }}",
title="📥 Profile Imported",
)
)
except Exception as e:
console.print(f"[red]Import failed: {e}[/red]")
sys.exit(1)
@cli.command()
@click.option("--profile", "-p", help="Profile path to export from (default: current)")
@click.option("--to", "-t", required=True, help="Export destination path")
@click.pass_context
def export_profile(ctx: click.Context, profile: Optional[str], to: str) -> None:
"""Export Chrome profile for sharing"""
config: ServerConfig = ctx.obj["config"]
source_profile = profile or config.auth.profile_dir
try:
from pathlib import Path
from shutil import copytree, rmtree
source = Path(source_profile)
dest = Path(to)
if not source.exists():
raise ConfigurationError(f"Source profile not found: {source}")
if dest.exists():
rmtree(dest)
copytree(source, dest)
console.print(
Panel.fit(
f"[bold green]✅ Profile Export Complete![/bold green]\n\n"
f"📁 From: {source}\n"
f"📁 To: {dest}\n\n"
f"[yellow]Share this profile with others for quick setup![/yellow]",
title="📤 Profile Exported",
)
)
except Exception as e:
console.print(f"[red]Export failed: {e}[/red]")
sys.exit(1)
@cli.command()
@click.pass_context
def config_show(ctx: click.Context) -> None:
"""Show current configuration"""
config: ServerConfig = ctx.obj["config"]
table = Table(title="Configuration")
table.add_column("Setting", style="cyan")
table.add_column("Value", style="yellow")
config_dict = config.to_dict()
for key, value in config_dict.items():
if isinstance(value, dict):
for sub_key, sub_value in value.items():
table.add_row(f"{key}.{sub_key}", str(sub_value))
else:
table.add_row(key, str(value))
console.print(table)
@cli.command()
@click.option("--notebook", "-n", required=True, help="Notebook ID to test")
@click.option("--headless", is_flag=True, help="Run in headless mode")
@click.pass_context
def test(ctx: click.Context, notebook: str, headless: bool) -> None:
"""Test connection to NotebookLM"""
config: ServerConfig = ctx.obj["config"]
config.default_notebook_id = notebook
if headless:
config.headless = True
async def run_test() -> None:
client = NotebookLMClient(config)
try:
console.print("[yellow]Testing browser startup...[/yellow]")
await client.start()
console.print("✅ Browser started successfully")
console.print("[yellow]Testing authentication...[/yellow]")
auth_success = await client.authenticate()
if auth_success:
console.print("✅ Authentication successful")
else:
console.print("⚠️ Authentication required - manual login needed")
console.print("[yellow]Testing notebook navigation...[/yellow]")
url = await client.navigate_to_notebook(notebook)
console.print(f"✅ Navigated to: {url}")
console.print("[green]All tests passed![/green]")
except Exception as e:
console.print(f"[red]Test failed: {e}[/red]")
raise
finally:
await client.close()
try:
asyncio.run(run_test())
except Exception as e:
console.print(f"[red]Test error: {e}[/red]")
sys.exit(1)
async def guided_setup(config: ServerConfig) -> bool:
"""Guided setup flow for first-time users
Returns:
bool: True if setup was successful, False otherwise
"""
console.print("[bold blue]🔧 Setting up browser and profile...[/bold blue]")
client = NotebookLMClient(config)
setup_success = False
try:
# Start browser
console.print("[yellow]Starting browser...[/yellow]")
await client.start()
console.print("✅ Browser started successfully")
# Navigate to notebook for authentication
if not config.default_notebook_id:
raise ValueError("No notebook ID configured")
console.print(
f"[yellow]Navigating to notebook: {config.default_notebook_id}[/yellow]"
)
await client.navigate_to_notebook(config.default_notebook_id)
# Check if already authenticated
auth_success = await client.authenticate()
if not auth_success:
console.print(
Panel.fit(
"[bold yellow]📋 Manual Login Required[/bold yellow]\n\n"
"Please complete the following steps:\n"
"1. 🔐 Login with your Google account in the browser\n"
"2. ✅ Ensure you can access the notebook\n"
"3. ⏱️ Wait for the page to fully load\n"
"4. ⌨️ Press Enter when ready...",
title="🔑 Authentication Setup",
)
)
if not config.headless:
input("\nPress Enter when login is complete...")
# Verify authentication after manual login
auth_success = await client.authenticate()
if auth_success:
console.print("✅ Authentication successful!")
else:
console.print(
"⚠️ Authentication verification failed, but profile was saved"
)
else:
console.print("✅ Already authenticated!")
# Test basic functionality
console.print("[yellow]Testing basic functionality...[/yellow]")
try:
await client.send_message("Hello, this is a test message from setup.")
console.print("✅ Chat functionality working")
setup_success = True # Mark as successful
except Exception as e:
console.print(f"⚠️ Chat test failed: {e}")
setup_success = auth_success # Success if at least authenticated
console.print("[bold green]✅ Browser profile setup complete![/bold green]")
return setup_success
except Exception as e:
console.print(f"[red]Setup error: {e}[/red]")
raise
finally:
if client:
await client.close()
def main() -> None:
"""Main entry point"""
cli()
if __name__ == "__main__":
main()