This is page 1 of 2. Use http://codebase.md/pleaseprompto/notebooklm-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .gitignore
├── CHANGELOG.md
├── docs
│ ├── configuration.md
│ ├── tools.md
│ ├── troubleshooting.md
│ └── usage-guide.md
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│ ├── auth
│ │ └── auth-manager.ts
│ ├── config.ts
│ ├── errors.ts
│ ├── index.ts
│ ├── library
│ │ ├── notebook-library.ts
│ │ └── types.ts
│ ├── resources
│ │ └── resource-handlers.ts
│ ├── session
│ │ ├── browser-session.ts
│ │ ├── session-manager.ts
│ │ └── shared-context-manager.ts
│ ├── tools
│ │ ├── definitions
│ │ │ ├── ask-question.ts
│ │ │ ├── notebook-management.ts
│ │ │ ├── session-management.ts
│ │ │ └── system.ts
│ │ ├── definitions.ts
│ │ ├── handlers.ts
│ │ └── index.ts
│ ├── types.ts
│ └── utils
│ ├── cleanup-manager.ts
│ ├── cli-handler.ts
│ ├── logger.ts
│ ├── page-utils.ts
│ ├── settings-manager.ts
│ └── stealth-utils.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Dependencies
2 | node_modules/
3 |
4 | # Build output
5 | dist/
6 | *.tsbuildinfo
7 |
8 | # Environment
9 | .env
10 | .env.local
11 |
12 | # Data directories (auth & sessions)
13 | data/
14 | browser_state/
15 | chrome_profile/
16 | .notebooklm-mcp/
17 | .claude/
18 | CLAUDE.md
19 |
20 | # IDE
21 | .vscode/
22 | .idea/
23 | *.swp
24 | *.swo
25 | *~
26 |
27 | # OS
28 | .DS_Store
29 | Thumbs.db
30 |
31 | # Logs
32 | *.log
33 | npm-debug.log*
34 | screenshots/
35 |
36 | # Archives / local notes
37 | *.tar.gz
38 | mcp-add-command.txt
39 | docs/why-notebooklm.md
40 |
41 | # Python (from old version)
42 | __pycache__/
43 | *.py[cod]
44 | .venv/
45 | venv/
46 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | <div align="center">
2 |
3 | # NotebookLM MCP Server
4 |
5 | **Let your CLI agents (Claude, Cursor, Codex...) chat directly with NotebookLM for zero-hallucination answers based on your own notebooks**
6 |
7 | [](https://www.typescriptlang.org/)
8 | [](https://modelcontextprotocol.io/)
9 | [](https://www.npmjs.com/package/notebooklm-mcp)
10 | [](https://github.com/PleasePrompto/notebooklm-skill)
11 | [](https://github.com/PleasePrompto/notebooklm-mcp)
12 |
13 | [Installation](#installation) • [Quick Start](#quick-start) • [Why NotebookLM](#why-notebooklm-not-local-rag) • [Examples](#real-world-example) • [Claude Code Skill](https://github.com/PleasePrompto/notebooklm-skill) • [Documentation](./docs/)
14 |
15 | </div>
16 |
17 | ---
18 |
19 | ## The Problem
20 |
21 | When you tell Claude Code or Cursor to "search through my local documentation", here's what happens:
22 | - **Massive token consumption**: Searching through documentation means reading multiple files repeatedly
23 | - **Inaccurate retrieval**: Searches for keywords, misses context and connections between docs
24 | - **Hallucinations**: When it can't find something, it invents plausible-sounding APIs
25 | - **Expensive & slow**: Each question requires re-reading multiple files
26 |
27 | ## The Solution
28 |
29 | Let your local agents chat directly with [**NotebookLM**](https://notebooklm.google/) — Google's **zero-hallucination knowledge base** powered by Gemini 2.5 that provides intelligent, synthesized answers from your docs.
30 |
31 | ```
32 | Your Task → Local Agent asks NotebookLM → Gemini synthesizes answer → Agent writes correct code
33 | ```
34 |
35 | **The real advantage**: No more manual copy-paste between NotebookLM and your editor. Your agent asks NotebookLM directly and gets answers straight back in the CLI. It builds deep understanding through automatic follow-ups — Claude asks multiple questions in sequence, each building on the last, getting specific implementation details, edge cases, and best practices. You can save NotebookLM links to your local library with tags and descriptions, and Claude automatically selects the relevant notebook based on your current task.
36 |
37 | ---
38 |
39 | ## Why NotebookLM, Not Local RAG?
40 |
41 | | Approach | Token Cost | Setup Time | Hallucinations | Answer Quality |
42 | |----------|------------|------------|----------------|----------------|
43 | | **Feed docs to Claude** | 🔴 Very high (multiple file reads) | Instant | Yes - fills gaps | Variable retrieval |
44 | | **Web search** | 🟡 Medium | Instant | High - unreliable sources | Hit or miss |
45 | | **Local RAG** | 🟡 Medium-High | Hours (embeddings, chunking) | Medium - retrieval gaps | Depends on setup |
46 | | **NotebookLM MCP** | 🟢 Minimal | 5 minutes | **Zero** - refuses if unknown | Expert synthesis |
47 |
48 | ### What Makes NotebookLM Superior?
49 |
50 | 1. **Pre-processed by Gemini**: Upload docs once, get instant expert knowledge
51 | 2. **Natural language Q&A**: Not just retrieval — actual understanding and synthesis
52 | 3. **Multi-source correlation**: Connects information across 50+ documents
53 | 4. **Citation-backed**: Every answer includes source references
54 | 5. **No infrastructure**: No vector DBs, embeddings, or chunking strategies needed
55 |
56 | ---
57 |
58 | ## Installation
59 |
60 | ### Claude Code
61 | ```bash
62 | claude mcp add notebooklm npx notebooklm-mcp@latest
63 | ```
64 |
65 | ### Codex
66 | ```bash
67 | codex mcp add notebooklm -- npx notebooklm-mcp@latest
68 | ```
69 |
70 | <details>
71 | <summary>Gemini</summary>
72 |
73 | ```bash
74 | gemini mcp add notebooklm npx notebooklm-mcp@latest
75 | ```
76 | </details>
77 |
78 | <details>
79 | <summary>Cursor</summary>
80 |
81 | Add to `~/.cursor/mcp.json`:
82 | ```json
83 | {
84 | "mcpServers": {
85 | "notebooklm": {
86 | "command": "npx",
87 | "args": ["-y", "notebooklm-mcp@latest"]
88 | }
89 | }
90 | }
91 | ```
92 | </details>
93 |
94 | <details>
95 | <summary>amp</summary>
96 |
97 | ```bash
98 | amp mcp add notebooklm -- npx notebooklm-mcp@latest
99 | ```
100 | </details>
101 |
102 | <details>
103 | <summary>VS Code</summary>
104 |
105 | ```bash
106 | code --add-mcp '{"name":"notebooklm","command":"npx","args":["notebooklm-mcp@latest"]}'
107 | ```
108 | </details>
109 |
110 | <details>
111 | <summary>Other MCP clients</summary>
112 |
113 | **Generic MCP config:**
114 | ```json
115 | {
116 | "mcpServers": {
117 | "notebooklm": {
118 | "command": "npx",
119 | "args": ["notebooklm-mcp@latest"]
120 | }
121 | }
122 | }
123 | ```
124 | </details>
125 |
126 | ---
127 |
128 | ## Alternative: Claude Code Skill
129 |
130 | **Prefer Claude Code Skills over MCP?** This server is now also available as a native Claude Code Skill with a simpler setup:
131 |
132 | **[NotebookLM Claude Code Skill](https://github.com/PleasePrompto/notebooklm-skill)** - Clone to `~/.claude/skills` and start using immediately
133 |
134 | **Key differences:**
135 | - **MCP Server** (this repo): Persistent sessions, works with Claude Code, Codex, Cursor, and other MCP clients
136 | - **Claude Code Skill**: Simpler setup, Python-based, stateless queries, works only with local Claude Code
137 |
138 | Both use the same browser automation technology and provide zero-hallucination answers from your NotebookLM notebooks.
139 |
140 | ---
141 |
142 | ## Quick Start
143 |
144 | ### 1. Install the MCP server (see [Installation](#installation) above)
145 |
146 | ### 2. Authenticate (one-time)
147 |
148 | Say in your chat (Claude/Codex):
149 | ```
150 | "Log me in to NotebookLM"
151 | ```
152 | *A Chrome window opens → log in with Google*
153 |
154 | ### 3. Create your knowledge base
155 | Go to [notebooklm.google.com](https://notebooklm.google.com) → Create notebook → Upload your docs:
156 | - 📄 PDFs, Google Docs, markdown files
157 | - 🔗 Websites, GitHub repos
158 | - 🎥 YouTube videos
159 | - 📚 Multiple sources per notebook
160 |
161 | Share: **⚙️ Share → Anyone with link → Copy**
162 |
163 | ### 4. Let Claude use it
164 | ```
165 | "I'm building with [library]. Here's my NotebookLM: [link]"
166 | ```
167 |
168 | **That's it.** Claude now asks NotebookLM whatever it needs, building expertise before writing code.
169 |
170 | ---
171 |
172 | ## Real-World Example
173 |
174 | ### Building an n8n Workflow Without Hallucinations
175 |
176 | **Challenge**: n8n's API is new — Claude hallucinates node names and functions.
177 |
178 | **Solution**:
179 | 1. Downloaded complete n8n documentation → merged into manageable chunks
180 | 2. Uploaded to NotebookLM
181 | 3. Told Claude: *"Build me a Gmail spam filter workflow. Use this NotebookLM: [link]"*
182 |
183 | **Watch the AI-to-AI conversation:**
184 |
185 | ```
186 | Claude → "How does Gmail integration work in n8n?"
187 | NotebookLM → "Use Gmail Trigger with polling, or Gmail node with Get Many..."
188 |
189 | Claude → "How to decode base64 email body?"
190 | NotebookLM → "Body is base64url encoded in payload.parts, use Function node..."
191 |
192 | Claude → "How to parse OpenAI response as JSON?"
193 | NotebookLM → "Set responseFormat to json, use {{ $json.spam }} in IF node..."
194 |
195 | Claude → "What about error handling if the API fails?"
196 | NotebookLM → "Use Error Trigger node with Continue On Fail enabled..."
197 |
198 | Claude → ✅ "Here's your complete workflow JSON..."
199 | ```
200 |
201 | **Result**: Perfect workflow on first try. No debugging hallucinated APIs.
202 |
203 | ---
204 |
205 | ## Core Features
206 |
207 | ### **Zero Hallucinations**
208 | NotebookLM refuses to answer if information isn't in your docs. No invented APIs.
209 |
210 | ### **Autonomous Research**
211 | Claude asks follow-up questions automatically, building complete understanding before coding.
212 |
213 | ### **Smart Library Management**
214 | Save NotebookLM links with tags and descriptions. Claude auto-selects the right notebook for your task.
215 | ```
216 | "Add [link] to library tagged 'frontend, react, components'"
217 | ```
218 |
219 | ### **Deep, Iterative Research**
220 | - Claude automatically asks follow-up questions to build complete understanding
221 | - Each answer triggers deeper questions until Claude has all the details
222 | - Example: For n8n workflow, Claude asked multiple sequential questions about Gmail integration, error handling, and data transformation
223 |
224 | ### **Cross-Tool Sharing**
225 | Set up once, use everywhere. Claude Code, Codex, Cursor — all share the same library.
226 |
227 | ### **Deep Cleanup Tool**
228 | Fresh start anytime. Scans entire system for NotebookLM data with categorized preview.
229 |
230 | ---
231 |
232 | ## Tool Profiles
233 |
234 | Reduce token usage by loading only the tools you need. Each tool consumes context tokens — fewer tools = faster responses and lower costs.
235 |
236 | ### Available Profiles
237 |
238 | | Profile | Tools | Use Case |
239 | |---------|-------|----------|
240 | | **minimal** | 5 | Query-only: `ask_question`, `get_health`, `list_notebooks`, `select_notebook`, `get_notebook` |
241 | | **standard** | 10 | + Library management: `setup_auth`, `list_sessions`, `add_notebook`, `update_notebook`, `search_notebooks` |
242 | | **full** | 16 | All tools including `cleanup_data`, `re_auth`, `remove_notebook`, `reset_session`, `close_session`, `get_library_stats` |
243 |
244 | ### Configure via CLI
245 |
246 | ```bash
247 | # Check current settings
248 | npx notebooklm-mcp config get
249 |
250 | # Set a profile
251 | npx notebooklm-mcp config set profile minimal
252 | npx notebooklm-mcp config set profile standard
253 | npx notebooklm-mcp config set profile full
254 |
255 | # Disable specific tools (comma-separated)
256 | npx notebooklm-mcp config set disabled-tools "cleanup_data,re_auth"
257 |
258 | # Reset to defaults
259 | npx notebooklm-mcp config reset
260 | ```
261 |
262 | ### Configure via Environment Variables
263 |
264 | ```bash
265 | # Set profile
266 | export NOTEBOOKLM_PROFILE=minimal
267 |
268 | # Disable specific tools
269 | export NOTEBOOKLM_DISABLED_TOOLS="cleanup_data,re_auth,remove_notebook"
270 | ```
271 |
272 | Settings are saved to `~/.config/notebooklm-mcp/settings.json` and persist across sessions. Environment variables override file settings.
273 |
274 | ---
275 |
276 | ## Architecture
277 |
278 | ```mermaid
279 | graph LR
280 | A[Your Task] --> B[Claude/Codex]
281 | B --> C[MCP Server]
282 | C --> D[Chrome Automation]
283 | D --> E[NotebookLM]
284 | E --> F[Gemini 2.5]
285 | F --> G[Your Docs]
286 | G --> F
287 | F --> E
288 | E --> D
289 | D --> C
290 | C --> B
291 | B --> H[Accurate Code]
292 | ```
293 |
294 | ---
295 |
296 | ## Common Commands
297 |
298 | | Intent | Say | Result |
299 | |--------|-----|--------|
300 | | Authenticate | *"Open NotebookLM auth setup"* or *"Log me in to NotebookLM"* | Chrome opens for login |
301 | | Add notebook | *"Add [link] to library"* | Saves notebook with metadata |
302 | | List notebooks | *"Show our notebooks"* | Lists all saved notebooks |
303 | | Research first | *"Research this in NotebookLM before coding"* | Multi-question session |
304 | | Select notebook | *"Use the React notebook"* | Sets active notebook |
305 | | Update notebook | *"Update notebook tags"* | Modify metadata |
306 | | Remove notebook | *"Remove [notebook] from library"* | Deletes from library |
307 | | View browser | *"Show me the browser"* | Watch live NotebookLM chat |
308 | | Fix auth | *"Repair NotebookLM authentication"* | Clears and re-authenticates |
309 | | Switch account | *"Re-authenticate with different Google account"* | Changes account |
310 | | Clean restart | *"Run NotebookLM cleanup"* | Removes all data for fresh start |
311 | | Keep library | *"Cleanup but keep my library"* | Preserves notebooks |
312 | | Delete all data | *"Delete all NotebookLM data"* | Complete removal |
313 |
314 | ---
315 |
316 | ## Comparison to Alternatives
317 |
318 | ### vs. Downloading docs locally
319 | - **You**: Download docs → Claude: "search through these files"
320 | - **Problem**: Claude reads thousands of files → massive token usage, often misses connections
321 | - **NotebookLM**: Pre-indexed by Gemini, semantic understanding across all docs
322 |
323 | ### vs. Web search
324 | - **You**: "Research X online"
325 | - **Problem**: Outdated info, hallucinated examples, unreliable sources
326 | - **NotebookLM**: Only your trusted docs, always current, with citations
327 |
328 | ### vs. Local RAG setup
329 | - **You**: Set up embeddings, vector DB, chunking strategy, retrieval pipeline
330 | - **Problem**: Hours of setup, tuning retrieval, still gets "creative" with gaps
331 | - **NotebookLM**: Upload docs → done. Google handles everything.
332 |
333 | ---
334 |
335 | ## FAQ
336 |
337 | **Is it really zero hallucinations?**
338 | Yes. NotebookLM is specifically designed to only answer from uploaded sources. If it doesn't know, it says so.
339 |
340 | **What about rate limits?**
341 | Free tier has daily query limits per Google account. Quick account switching supported for continued research.
342 |
343 | **How secure is this?**
344 | Chrome runs locally. Your credentials never leave your machine. Use a dedicated Google account if concerned.
345 |
346 | **Can I see what's happening?**
347 | Yes! Say *"Show me the browser"* to watch the live NotebookLM conversation.
348 |
349 | **What makes this better than Claude's built-in knowledge?**
350 | Your docs are always current. No training cutoff. No hallucinations. Perfect for new libraries, internal APIs, or fast-moving projects.
351 |
352 | ---
353 |
354 | ## Advanced Usage
355 |
356 | - 📖 [**Usage Guide**](./docs/usage-guide.md) — Patterns, workflows, tips
357 | - 🛠️ [**Tool Reference**](./docs/tools.md) — Complete MCP API
358 | - 🔧 [**Configuration**](./docs/configuration.md) — Environment variables
359 | - 🐛 [**Troubleshooting**](./docs/troubleshooting.md) — Common issues
360 |
361 | ---
362 |
363 | ## The Bottom Line
364 |
365 | **Without NotebookLM MCP**: Write code → Find it's wrong → Debug hallucinated APIs → Repeat
366 |
367 | **With NotebookLM MCP**: Claude researches first → Writes correct code → Ship faster
368 |
369 | Stop debugging hallucinations. Start shipping accurate code.
370 |
371 | ```bash
372 | # Get started in 30 seconds
373 | claude mcp add notebooklm npx notebooklm-mcp@latest
374 | ```
375 |
376 | ---
377 |
378 | ## Disclaimer
379 |
380 | This tool automates browser interactions with NotebookLM to make your workflow more efficient. However, a few friendly reminders:
381 |
382 | **About browser automation:**
383 | While I've built in humanization features (realistic typing speeds, natural delays, mouse movements) to make the automation behave more naturally, I can't guarantee Google won't detect or flag automated usage. I recommend using a dedicated Google account for automation rather than your primary account—think of it like web scraping: probably fine, but better safe than sorry!
384 |
385 | **About CLI tools and AI agents:**
386 | CLI tools like Claude Code, Codex, and similar AI-powered assistants are incredibly powerful, but they can make mistakes. Please use them with care and awareness:
387 | - Always review changes before committing or deploying
388 | - Test in safe environments first
389 | - Keep backups of important work
390 | - Remember: AI agents are assistants, not infallible oracles
391 |
392 | I built this tool for myself because I was tired of the copy-paste dance between NotebookLM and my editor. I'm sharing it in the hope it helps others too, but I can't take responsibility for any issues, data loss, or account problems that might occur. Use at your own discretion and judgment.
393 |
394 | That said, if you run into problems or have questions, feel free to open an issue on GitHub. I'm happy to help troubleshoot!
395 |
396 | ---
397 |
398 | ## Contributing
399 |
400 | Found a bug? Have a feature idea? [Open an issue](https://github.com/PleasePrompto/notebooklm-mcp/issues) or submit a PR!
401 |
402 | ## License
403 |
404 | MIT — Use freely in your projects.
405 |
406 | ---
407 |
408 | <div align="center">
409 |
410 | Built with frustration about hallucinated APIs, powered by Google's NotebookLM
411 |
412 | ⭐ [Star on GitHub](https://github.com/PleasePrompto/notebooklm-mcp) if this saves you debugging time!
413 |
414 | </div>
415 |
```
--------------------------------------------------------------------------------
/src/tools/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * MCP Tools Module
3 | *
4 | * Exports tool definitions and handlers.
5 | */
6 |
7 | export { buildToolDefinitions } from "./definitions.js";
8 | export { ToolHandlers } from "./handlers.js";
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "Node16",
5 | "moduleResolution": "Node16",
6 | "outDir": "./dist",
7 | "rootDir": "./src",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "skipLibCheck": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "resolveJsonModule": true,
13 | "declaration": true,
14 | "declarationMap": true,
15 | "sourceMap": true,
16 | "lib": ["ES2022"],
17 | "types": ["node"],
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true
20 | },
21 | "include": ["src/**/*"],
22 | "exclude": ["node_modules", "dist", "Old_Python_Vesion"]
23 | }
24 |
```
--------------------------------------------------------------------------------
/src/tools/definitions.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * MCP Tool Definitions
3 | *
4 | * Aggregates tool definitions from sub-modules.
5 | */
6 |
7 | import { Tool } from "@modelcontextprotocol/sdk/types.js";
8 | import { NotebookLibrary } from "../library/notebook-library.js";
9 | import {
10 | askQuestionTool,
11 | buildAskQuestionDescription,
12 | } from "./definitions/ask-question.js";
13 | import { notebookManagementTools } from "./definitions/notebook-management.js";
14 | import { sessionManagementTools } from "./definitions/session-management.js";
15 | import { systemTools } from "./definitions/system.js";
16 |
17 | /**
18 | * Build Tool Definitions with NotebookLibrary context
19 | */
20 | export function buildToolDefinitions(library: NotebookLibrary): Tool[] {
21 | // Update the description for ask_question based on the library state
22 | const dynamicAskQuestionTool = {
23 | ...askQuestionTool,
24 | description: buildAskQuestionDescription(library),
25 | };
26 |
27 | return [
28 | dynamicAskQuestionTool,
29 | ...notebookManagementTools,
30 | ...sessionManagementTools,
31 | ...systemTools,
32 | ];
33 | }
```
--------------------------------------------------------------------------------
/src/tools/definitions/session-management.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Tool } from "@modelcontextprotocol/sdk/types.js";
2 |
3 | export const sessionManagementTools: Tool[] = [
4 | {
5 | name: "list_sessions",
6 | description:
7 | "List all active sessions with stats (age, message count, last activity). " +
8 | "Use to continue the most relevant session instead of starting from scratch.",
9 | inputSchema: {
10 | type: "object",
11 | properties: {},
12 | },
13 | },
14 | {
15 | name: "close_session",
16 | description: "Close a specific session by session ID. Ask before closing if the user might still need it.",
17 | inputSchema: {
18 | type: "object",
19 | properties: {
20 | session_id: {
21 | type: "string",
22 | description: "The session ID to close",
23 | },
24 | },
25 | required: ["session_id"],
26 | },
27 | },
28 | {
29 | name: "reset_session",
30 | description:
31 | "Reset a session's chat history (keep same session ID). " +
32 | "Use for a clean slate when the task changes; ask the user before resetting.",
33 | inputSchema: {
34 | type: "object",
35 | properties: {
36 | session_id: {
37 | type: "string",
38 | description: "The session ID to reset",
39 | },
40 | },
41 | required: ["session_id"],
42 | },
43 | },
44 | ];
45 |
```
--------------------------------------------------------------------------------
/src/errors.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Custom Error Types for NotebookLM MCP Server
3 | */
4 |
5 | /**
6 | * Error thrown when NotebookLM rate limit is exceeded
7 | *
8 | * Free users have 50 queries/day limit.
9 | * This error indicates the user should:
10 | * - Use re_auth tool to switch Google accounts
11 | * - Wait until tomorrow for quota reset
12 | * - Upgrade to Google AI Pro/Ultra for higher limits
13 | */
14 | export class RateLimitError extends Error {
15 | constructor(message: string = "NotebookLM rate limit reached (50 queries/day for free accounts)") {
16 | super(message);
17 | this.name = "RateLimitError";
18 |
19 | // Maintain proper stack trace for where error was thrown (V8 only)
20 | if (Error.captureStackTrace) {
21 | Error.captureStackTrace(this, RateLimitError);
22 | }
23 | }
24 | }
25 |
26 | /**
27 | * Error thrown when authentication fails
28 | *
29 | * This error can suggest cleanup workflow for persistent issues.
30 | * Especially useful when upgrading from old installation (notebooklm-mcp-nodejs).
31 | */
32 | export class AuthenticationError extends Error {
33 | suggestCleanup: boolean;
34 |
35 | constructor(message: string, suggestCleanup: boolean = false) {
36 | super(message);
37 | this.name = "AuthenticationError";
38 | this.suggestCleanup = suggestCleanup;
39 |
40 | // Maintain proper stack trace for where error was thrown (V8 only)
41 | if (Error.captureStackTrace) {
42 | Error.captureStackTrace(this, AuthenticationError);
43 | }
44 | }
45 | }
46 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "notebooklm-mcp",
3 | "version": "1.2.0",
4 | "description": "MCP server for NotebookLM API with session support and human-like behavior",
5 | "type": "module",
6 | "bin": {
7 | "notebooklm-mcp": "dist/index.js"
8 | },
9 | "scripts": {
10 | "build": "tsc",
11 | "postbuild": "chmod +x dist/index.js",
12 | "watch": "tsc --watch",
13 | "dev": "tsx watch src/index.ts",
14 | "prepare": "npm run build",
15 | "test": "tsx src/index.ts"
16 | },
17 | "keywords": [
18 | "mcp",
19 | "notebooklm",
20 | "gemini",
21 | "ai",
22 | "claude"
23 | ],
24 | "author": "Gérôme Dexheimer <[email protected]> (https://github.com/PleasePrompto)",
25 | "license": "MIT",
26 | "repository": {
27 | "type": "git",
28 | "url": "git+https://github.com/PleasePrompto/notebooklm-mcp.git"
29 | },
30 | "homepage": "https://github.com/PleasePrompto/notebooklm-mcp#readme",
31 | "bugs": {
32 | "url": "https://github.com/PleasePrompto/notebooklm-mcp/issues"
33 | },
34 | "files": [
35 | "dist",
36 | "README.md",
37 | "NOTEBOOKLM_USAGE.md",
38 | "LICENSE",
39 | "docs"
40 | ],
41 | "dependencies": {
42 | "@modelcontextprotocol/sdk": "^1.0.0",
43 | "dotenv": "^16.4.0",
44 | "env-paths": "^3.0.0",
45 | "globby": "^14.1.0",
46 | "patchright": "^1.48.2",
47 | "zod": "^3.22.0"
48 | },
49 | "devDependencies": {
50 | "@types/node": "^20.11.0",
51 | "tsx": "^4.7.0",
52 | "typescript": "^5.3.3"
53 | },
54 | "engines": {
55 | "node": ">=18.0.0"
56 | }
57 | }
58 |
```
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Global type definitions for NotebookLM MCP Server
3 | */
4 |
5 | /**
6 | * Session information returned by the API
7 | */
8 | export interface SessionInfo {
9 | id: string;
10 | created_at: number;
11 | last_activity: number;
12 | age_seconds: number;
13 | inactive_seconds: number;
14 | message_count: number;
15 | notebook_url: string;
16 | }
17 |
18 | /**
19 | * Result from asking a question
20 | */
21 | export interface AskQuestionResult {
22 | status: "success" | "error";
23 | question: string;
24 | answer?: string;
25 | error?: string;
26 | notebook_url: string;
27 | session_id?: string;
28 | session_info?: {
29 | age_seconds: number;
30 | message_count: number;
31 | last_activity: number;
32 | };
33 | }
34 |
35 | /**
36 | * Tool call result for MCP (generic wrapper for tool responses)
37 | */
38 | export interface ToolResult<T = any> {
39 | success: boolean;
40 | data?: T;
41 | error?: string;
42 | }
43 |
44 | /**
45 | * MCP Tool definition
46 | */
47 | export interface Tool {
48 | name: string;
49 | title?: string;
50 | description: string;
51 | inputSchema: {
52 | type: "object";
53 | properties: Record<string, any>;
54 | required?: string[];
55 | };
56 | }
57 |
58 | /**
59 | * Options for human-like typing
60 | */
61 | export interface TypingOptions {
62 | wpm?: number; // Words per minute
63 | withTypos?: boolean;
64 | }
65 |
66 | /**
67 | * Options for waiting for answers
68 | */
69 | export interface WaitForAnswerOptions {
70 | question?: string;
71 | timeoutMs?: number;
72 | pollIntervalMs?: number;
73 | ignoreTexts?: string[];
74 | debug?: boolean;
75 | }
76 |
77 | /**
78 | * Progress callback function for MCP progress notifications
79 | */
80 | export type ProgressCallback = (
81 | message: string,
82 | progress?: number,
83 | total?: number
84 | ) => Promise<void>;
85 |
86 | /**
87 | * Global state for the server
88 | */
89 | export interface ServerState {
90 | playwright: any;
91 | sessionManager: any;
92 | authManager: any;
93 | }
94 |
```
--------------------------------------------------------------------------------
/docs/tools.md:
--------------------------------------------------------------------------------
```markdown
1 | ## Tools
2 |
3 | ### Core
4 | - `ask_question`
5 | - Parameters: `question` (string, required), optional `session_id`, `notebook_id`, `notebook_url`, `show_browser`.
6 | - Returns NotebookLM's answer plus the follow-up reminder.
7 | - `list_sessions`, `close_session`, `reset_session`
8 | - Inspect or manage active browser sessions.
9 | - `get_health`
10 | - Summaries auth status, active sessions, and configuration.
11 | - `setup_auth`
12 | - Opens the persistent Chrome profile so you can log in manually.
13 | - `re_auth`
14 | - Switch to a different Google account or re-authenticate.
15 | - Use when NotebookLM rate limit is reached (50 queries/day for free accounts).
16 | - Closes all sessions, clears auth data, and opens browser for fresh login.
17 |
18 | ### Notebook library
19 | - `add_notebook` – Safe conversational add; expects confirmation before writing.
20 | - `list_notebooks` – Returns id, name, topics, URL, metadata for every entry.
21 | - `get_notebook` – Fetch a single notebook by id.
22 | - `select_notebook` – Set the active default notebook.
23 | - `update_notebook` – Modify metadata fields.
24 | - `remove_notebook` – Removes entries from the library (not the original NotebookLM notebook).
25 | - `search_notebooks` – Simple query across name/description/topics/tags.
26 | - `get_library_stats` – Aggregate statistics (total notebooks, usage counts, etc.).
27 |
28 | ### Resources
29 | - `notebooklm://library`
30 | - JSON representation of the full library: active notebook, stats, individual notebooks.
31 | - `notebooklm://library/{id}`
32 | - Fetch metadata for a specific notebook. The `{id}` completion pulls from the library automatically.
33 |
34 | **Remember:** Every `ask_question` response ends with a reminder that nudges your agent to keep asking until the user’s task is fully addressed.
35 |
```
--------------------------------------------------------------------------------
/src/library/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * NotebookLM Library Types
3 | *
4 | * Defines the structure for managing multiple NotebookLM notebooks
5 | * in a persistent library that Claude can manage autonomously.
6 | */
7 |
8 | /**
9 | * Single notebook entry in the library
10 | */
11 | export interface NotebookEntry {
12 | // Identification
13 | id: string; // Unique identifier (slug format, e.g., "n8n-docs")
14 | url: string; // NotebookLM URL
15 | name: string; // Display name (e.g., "n8n Workflow Automation")
16 |
17 | // Metadata for Claude's autonomous decision-making
18 | description: string; // What knowledge is in this notebook
19 | topics: string[]; // Topics covered
20 | content_types: string[]; // Types of content (docs, examples, etc.)
21 | use_cases: string[]; // When to use this notebook
22 |
23 | // Usage tracking
24 | added_at: string; // ISO timestamp when added
25 | last_used: string; // ISO timestamp of last use
26 | use_count: number; // How many times used
27 |
28 | // Optional tags for organization
29 | tags?: string[]; // Custom tags for filtering
30 | }
31 |
32 | /**
33 | * The complete notebook library
34 | */
35 | export interface Library {
36 | notebooks: NotebookEntry[]; // All notebooks in library
37 | active_notebook_id: string | null; // Currently selected notebook
38 | last_modified: string; // ISO timestamp of last modification
39 | version: string; // Library format version (for future migrations)
40 | }
41 |
42 | /**
43 | * Input for adding a new notebook
44 | */
45 | export interface AddNotebookInput {
46 | url: string; // Required: NotebookLM URL
47 | name: string; // Required: Display name
48 | description: string; // Required: What's in it
49 | topics: string[]; // Required: Topics covered
50 | content_types?: string[]; // Optional: defaults to ["documentation", "examples"]
51 | use_cases?: string[]; // Optional: defaults based on description
52 | tags?: string[]; // Optional: custom tags
53 | }
54 |
55 | /**
56 | * Input for updating a notebook
57 | */
58 | export interface UpdateNotebookInput {
59 | id: string; // Required: which notebook to update
60 | name?: string;
61 | description?: string;
62 | topics?: string[];
63 | content_types?: string[];
64 | use_cases?: string[];
65 | tags?: string[];
66 | url?: string; // Allow changing URL
67 | }
68 |
69 | /**
70 | * Statistics about library usage
71 | */
72 | export interface LibraryStats {
73 | total_notebooks: number;
74 | active_notebook: string | null;
75 | most_used_notebook: string | null;
76 | total_queries: number;
77 | last_modified: string;
78 | }
79 |
```
--------------------------------------------------------------------------------
/docs/troubleshooting.md:
--------------------------------------------------------------------------------
```markdown
1 | ## Troubleshooting
2 |
3 | ### Fresh start / Deep cleanup
4 | If you're experiencing persistent issues, corrupted data, or want to start completely fresh:
5 |
6 | **⚠️ CRITICAL: Close ALL Chrome/Chromium instances before cleanup!** Open browsers can prevent cleanup and cause issues.
7 |
8 | **Recommended workflow:**
9 | 1. Close all Chrome/Chromium windows and instances
10 | 2. Ask: "Run NotebookLM cleanup and preserve my library"
11 | 3. Review the preview - you'll see exactly what will be deleted
12 | 4. Confirm deletion
13 | 5. Re-authenticate: "Open NotebookLM auth setup"
14 |
15 | **What gets cleaned:**
16 | - Browser data, cache, Chrome profiles
17 | - Temporary files and logs
18 | - Old installation data
19 | - **Preserved:** Your notebook library (when using preserve option)
20 |
21 | **Useful for:**
22 | - Authentication problems
23 | - Browser session conflicts
24 | - Corrupted browser profiles
25 | - Clean reinstalls
26 | - Switching between accounts
27 |
28 | ### Browser closed / `newPage` errors
29 | - Symptom: `browserContext.newPage: Target page/context/browser has been closed`.
30 | - Fix: The server auto‑recovers (recreates context and page). Re‑run the tool.
31 |
32 | ### Profile lock / `ProcessSingleton` errors
33 | - Cause: Another Chrome is using the base profile.
34 | - Fix: `NOTEBOOK_PROFILE_STRATEGY=auto` (default) falls back to isolated per‑instance profiles; or set `isolated`.
35 |
36 | ### Authentication issues
37 | **Quick fix:** Ask the agent to repair authentication; it will run `get_health` → `setup_auth` → `get_health`.
38 |
39 | **For persistent auth failures:**
40 | 1. Close ALL Chrome/Chromium instances
41 | 2. Ask: "Run NotebookLM cleanup with library preservation"
42 | 3. After cleanup completes, ask: "Open NotebookLM auth setup"
43 | 4. This creates a completely fresh browser session while keeping your notebooks
44 |
45 | **Auto-login (optional):**
46 | - Set `AUTO_LOGIN_ENABLED=true` with `LOGIN_EMAIL`, `LOGIN_PASSWORD` environment variables
47 | - For automation workflows only
48 |
49 | ### Typing speed too slow/fast
50 | - Adjust `TYPING_WPM_MIN`/`MAX`; or disable stealth typing by setting `STEALTH_ENABLED=false`.
51 |
52 | ### Rate limit reached
53 | - Symptom: "NotebookLM rate limit reached (50 queries/day for free accounts)".
54 | - Fix: Use `re_auth` tool to switch to a different Google account, or wait until tomorrow.
55 | - Upgrade: Google AI Pro/Ultra gives 5x higher limits.
56 |
57 | ### No notebooks found
58 | - Ask to add the NotebookLM link you need.
59 | - Ask to list the stored notebooks, then choose the one to activate.
60 |
```
--------------------------------------------------------------------------------
/src/utils/logger.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Console logging utilities with colors and formatting
3 | * Similar to Python's rich.console
4 | */
5 |
6 | type LogLevel = "info" | "success" | "warning" | "error" | "debug" | "dim";
7 |
8 | interface LogStyle {
9 | prefix: string;
10 | color: string;
11 | }
12 |
13 | const STYLES: Record<LogLevel, LogStyle> = {
14 | info: { prefix: "ℹ️", color: "\x1b[36m" }, // Cyan
15 | success: { prefix: "✅", color: "\x1b[32m" }, // Green
16 | warning: { prefix: "⚠️", color: "\x1b[33m" }, // Yellow
17 | error: { prefix: "❌", color: "\x1b[31m" }, // Red
18 | debug: { prefix: "🔍", color: "\x1b[35m" }, // Magenta
19 | dim: { prefix: " ", color: "\x1b[2m" }, // Dim
20 | };
21 |
22 | const RESET = "\x1b[0m";
23 |
24 | /**
25 | * Logger class for consistent console output
26 | */
27 | export class Logger {
28 | private enabled: boolean;
29 |
30 | constructor(enabled: boolean = true) {
31 | this.enabled = enabled;
32 | }
33 |
34 | /**
35 | * Log a message with a specific style
36 | */
37 | log(message: string, level: LogLevel = "info"): void {
38 | if (!this.enabled) return;
39 |
40 | const style = STYLES[level];
41 | const timestamp = new Date().toISOString().split("T")[1].slice(0, 8);
42 | const formattedMessage = `${style.color}${style.prefix} [${timestamp}] ${message}${RESET}`;
43 |
44 | // Use stderr for logs to keep stdout clean for MCP JSON-RPC
45 | console.error(formattedMessage);
46 | }
47 |
48 | /**
49 | * Log info message
50 | */
51 | info(message: string): void {
52 | this.log(message, "info");
53 | }
54 |
55 | /**
56 | * Log success message
57 | */
58 | success(message: string): void {
59 | this.log(message, "success");
60 | }
61 |
62 | /**
63 | * Log warning message
64 | */
65 | warning(message: string): void {
66 | this.log(message, "warning");
67 | }
68 |
69 | /**
70 | * Log error message
71 | */
72 | error(message: string): void {
73 | this.log(message, "error");
74 | }
75 |
76 | /**
77 | * Log debug message
78 | */
79 | debug(message: string): void {
80 | this.log(message, "debug");
81 | }
82 |
83 | /**
84 | * Log dim message (for less important info)
85 | */
86 | dim(message: string): void {
87 | this.log(message, "dim");
88 | }
89 |
90 | /**
91 | * Enable or disable logging
92 | */
93 | setEnabled(enabled: boolean): void {
94 | this.enabled = enabled;
95 | }
96 | }
97 |
98 | /**
99 | * Global logger instance
100 | */
101 | export const logger = new Logger();
102 |
103 | /**
104 | * Convenience functions for quick logging
105 | */
106 | export const log = {
107 | info: (msg: string) => logger.info(msg),
108 | success: (msg: string) => logger.success(msg),
109 | warning: (msg: string) => logger.warning(msg),
110 | error: (msg: string) => logger.error(msg),
111 | debug: (msg: string) => logger.debug(msg),
112 | dim: (msg: string) => logger.dim(msg),
113 | };
114 |
```
--------------------------------------------------------------------------------
/src/utils/cli-handler.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * CLI Handler
3 | *
4 | * Handles CLI commands for configuration management.
5 | * Executed when the server is run with 'config' arguments.
6 | */
7 |
8 | import { SettingsManager, ProfileName } from "./settings-manager.js";
9 |
10 | export class CliHandler {
11 | private settingsManager: SettingsManager;
12 |
13 | constructor() {
14 | this.settingsManager = new SettingsManager();
15 | }
16 |
17 | async handleCommand(args: string[]): Promise<void> {
18 | const command = args[0];
19 | const subCommand = args[1];
20 |
21 | if (command !== "config") {
22 | return;
23 | }
24 |
25 | try {
26 | switch (subCommand) {
27 | case "set":
28 | await this.handleSet(args.slice(2));
29 | break;
30 | case "get":
31 | this.handleGet();
32 | break;
33 | case "reset":
34 | await this.handleReset();
35 | break;
36 | default:
37 | this.printHelp();
38 | }
39 | } catch (error) {
40 | console.error(`❌ Error: ${error instanceof Error ? error.message : String(error)}`);
41 | process.exit(1);
42 | }
43 | }
44 |
45 | private async handleSet(args: string[]): Promise<void> {
46 | const key = args[0];
47 | const value = args[1];
48 |
49 | if (!key || !value) {
50 | throw new Error("Usage: config set <key> <value>");
51 | }
52 |
53 | if (key === "profile") {
54 | if (!["minimal", "standard", "full"].includes(value)) {
55 | throw new Error("Invalid profile. Allowed: minimal, standard, full");
56 | }
57 | await this.settingsManager.saveSettings({ profile: value as ProfileName });
58 | console.log(`✅ Profile set to: ${value}`);
59 | } else if (key === "disabled-tools") {
60 | const tools = value.split(",").map(t => t.trim()).filter(t => t.length > 0);
61 | await this.settingsManager.saveSettings({ disabledTools: tools });
62 | console.log(`✅ Disabled tools set to: ${tools.join(", ") || "(none)"}`);
63 | } else {
64 | throw new Error(`Unknown setting: ${key}. Allowed: profile, disabled-tools`);
65 | }
66 | }
67 |
68 | private handleGet(): void {
69 | const settings = this.settingsManager.getEffectiveSettings();
70 | const profiles = this.settingsManager.getProfiles();
71 |
72 | console.log("🔧 Current Configuration:");
73 | console.log(` Profile: ${settings.profile}`);
74 | console.log(` Disabled Tools: ${settings.disabledTools.length > 0 ? settings.disabledTools.join(", ") : "(none)"}`);
75 | console.log(` Settings File: ${this.settingsManager.getSettingsPath()}`);
76 | console.log("");
77 | console.log("📋 Active Tools in this profile:");
78 |
79 | const activeInProfile = profiles[settings.profile];
80 | if (activeInProfile.includes("*")) {
81 | console.log(" - All Tools (except disabled)");
82 | } else {
83 | activeInProfile.forEach(t => console.log(` - ${t}`));
84 | }
85 | }
86 |
87 | private async handleReset(): Promise<void> {
88 | await this.settingsManager.saveSettings({
89 | profile: "full",
90 | disabledTools: []
91 | });
92 | console.log("✅ Configuration reset to defaults (Profile: full, No disabled tools)");
93 | }
94 |
95 | private printHelp(): void {
96 | console.log(`
97 | Usage: npx notebooklm-mcp config <command> [args]
98 |
99 | Commands:
100 | config get Show current configuration
101 | config set profile <name> Set profile (minimal, standard, full)
102 | config set disabled-tools <list> Set disabled tools (comma-separated)
103 | config reset Reset to default settings
104 |
105 | Profiles:
106 | minimal Essential read-only tools (low token usage)
107 | standard Read + Library management
108 | full All tools enabled
109 | `);
110 | }
111 | }
112 |
```
--------------------------------------------------------------------------------
/docs/configuration.md:
--------------------------------------------------------------------------------
```markdown
1 | ## Configuration
2 |
3 | **No config files needed!** The server works out of the box with sensible defaults.
4 |
5 | ### Configuration Priority (highest to lowest):
6 | 1. **Tool Parameters** - Claude passes settings like `browser_options` at runtime
7 | 2. **Environment Variables** - Optional overrides for advanced users
8 | 3. **Hardcoded Defaults** - Sensible defaults that work for most users
9 |
10 | ---
11 |
12 | ## Tool Parameters (Runtime Configuration)
13 |
14 | Claude can control browser behavior via the `browser_options` parameter in tools like `ask_question`, `setup_auth`, and `re_auth`:
15 |
16 | ```typescript
17 | browser_options: {
18 | show: boolean, // Show browser window (overrides headless)
19 | headless: boolean, // Run in headless mode (default: true)
20 | timeout_ms: number, // Browser timeout in ms (default: 30000)
21 |
22 | stealth: {
23 | enabled: boolean, // Master switch (default: true)
24 | random_delays: boolean, // Random delays between actions (default: true)
25 | human_typing: boolean, // Human-like typing (default: true)
26 | mouse_movements: boolean, // Realistic mouse movements (default: true)
27 | typing_wpm_min: number, // Min typing speed (default: 160)
28 | typing_wpm_max: number, // Max typing speed (default: 240)
29 | delay_min_ms: number, // Min delay between actions (default: 100)
30 | delay_max_ms: number, // Max delay between actions (default: 400)
31 | },
32 |
33 | viewport: {
34 | width: number, // Viewport width (default: 1024)
35 | height: number, // Viewport height (default: 768)
36 | }
37 | }
38 | ```
39 |
40 | **Example usage:**
41 | - "Research this and show me the browser" → Sets `show: true`
42 | - "Use slow typing for this query" → Adjusts typing WPM via stealth settings
43 |
44 | ---
45 |
46 | ## Environment Variables (Optional)
47 |
48 | For advanced users who want to set global defaults:
49 | - Auth
50 | - `AUTO_LOGIN_ENABLED` — `true|false` (default `false`)
51 | - `LOGIN_EMAIL`, `LOGIN_PASSWORD` — for auto‑login if enabled
52 | - `AUTO_LOGIN_TIMEOUT_MS` (default `120000`)
53 | - Stealth / Human-like behavior
54 | - `STEALTH_ENABLED` — `true|false` (default `true`) — Master switch for all stealth features
55 | - `STEALTH_RANDOM_DELAYS` — `true|false` (default `true`)
56 | - `STEALTH_HUMAN_TYPING` — `true|false` (default `true`)
57 | - `STEALTH_MOUSE_MOVEMENTS` — `true|false` (default `true`)
58 | - Typing speed (human‑like)
59 | - `TYPING_WPM_MIN` (default 160), `TYPING_WPM_MAX` (default 240)
60 | - Delays (human‑like)
61 | - `MIN_DELAY_MS` (default 100), `MAX_DELAY_MS` (default 400)
62 | - Browser
63 | - `HEADLESS` (default `true`), `BROWSER_TIMEOUT` (ms, default `30000`)
64 | - Sessions
65 | - `MAX_SESSIONS` (default 10), `SESSION_TIMEOUT` (s, default 900)
66 | - Multi‑instance profile strategy
67 | - `NOTEBOOK_PROFILE_STRATEGY` — `auto|single|isolated` (default `auto`)
68 | - `NOTEBOOK_CLONE_PROFILE` — clone base profile into isolated dir (default `false`)
69 | - Cleanup (to prevent disk bloat)
70 | - `NOTEBOOK_CLEANUP_ON_STARTUP` (default `true`)
71 | - `NOTEBOOK_CLEANUP_ON_SHUTDOWN` (default `true`)
72 | - `NOTEBOOK_INSTANCE_TTL_HOURS` (default `72`)
73 | - `NOTEBOOK_INSTANCE_MAX_COUNT` (default `20`)
74 | - Library metadata (optional hints)
75 | - `NOTEBOOK_DESCRIPTION`, `NOTEBOOK_TOPICS`, `NOTEBOOK_CONTENT_TYPES`, `NOTEBOOK_USE_CASES`
76 | - `NOTEBOOK_URL` — optional; leave empty and manage notebooks via the library
77 |
78 | ---
79 |
80 | ## Storage Paths
81 |
82 | The server uses platform-specific paths via [env-paths](https://github.com/sindresorhus/env-paths)
83 | - **Linux**: `~/.local/share/notebooklm-mcp/`
84 | - **macOS**: `~/Library/Application Support/notebooklm-mcp/`
85 | - **Windows**: `%LOCALAPPDATA%\notebooklm-mcp\`
86 |
87 | **What's stored:**
88 | - `chrome_profile/` - Persistent Chrome browser profile with login session
89 | - `browser_state/` - Browser context state and cookies
90 | - `library.json` - Your notebook library with metadata
91 | - `chrome_profile_instances/` - Isolated Chrome profiles for concurrent sessions
92 |
93 | **No config.json file** - Configuration is purely via environment variables or tool parameters!
94 |
95 |
```
--------------------------------------------------------------------------------
/src/utils/settings-manager.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Settings Manager
3 | *
4 | * Handles persistent configuration for the NotebookLM MCP Server.
5 | * Manages profiles, disabled tools, and environment variable overrides.
6 | */
7 |
8 | import fs from "fs/promises";
9 | import { existsSync, mkdirSync } from "fs";
10 | import path from "path";
11 | import { CONFIG } from "../config.js";
12 | import { log } from "./logger.js";
13 | import { Tool } from "@modelcontextprotocol/sdk/types.js";
14 |
15 | export type ProfileName = "minimal" | "standard" | "full";
16 |
17 | export interface Settings {
18 | profile: ProfileName;
19 | disabledTools: string[];
20 | customSettings?: Record<string, any>;
21 | }
22 |
23 | const DEFAULT_SETTINGS: Settings = {
24 | profile: "full",
25 | disabledTools: [],
26 | };
27 |
28 | const PROFILES: Record<ProfileName, string[]> = {
29 | minimal: [
30 | "ask_question",
31 | "get_health",
32 | "list_notebooks",
33 | "select_notebook",
34 | "get_notebook" // Added as it is read-only and useful
35 | ],
36 | standard: [
37 | "ask_question",
38 | "get_health",
39 | "list_notebooks",
40 | "select_notebook",
41 | "get_notebook",
42 | "setup_auth",
43 | "list_sessions",
44 | "add_notebook",
45 | "update_notebook",
46 | "search_notebooks"
47 | ],
48 | full: ["*"] // All tools
49 | };
50 |
51 | export class SettingsManager {
52 | private settingsPath: string;
53 | private settings: Settings;
54 |
55 | constructor() {
56 | // Use the config directory from env-paths defined in config.ts
57 | this.settingsPath = path.join(CONFIG.configDir, "settings.json");
58 | this.settings = this.loadSettings();
59 | }
60 |
61 | /**
62 | * Load settings from file, falling back to defaults
63 | */
64 | private loadSettings(): Settings {
65 | try {
66 | // Ensure config dir exists
67 | if (!existsSync(CONFIG.configDir)) {
68 | mkdirSync(CONFIG.configDir, { recursive: true });
69 | }
70 |
71 | if (existsSync(this.settingsPath)) {
72 | // Use fs.readFileSync for synchronous initialization in constructor if needed,
73 | // but here we used async fs in imports. For simplicity in constructor,
74 | // we'll assume the file is read when needed or require explicit init.
75 | // Actually, to keep it simple, let's use require/import or readFileSync.
76 | const fsSync = require("fs");
77 | const data = fsSync.readFileSync(this.settingsPath, "utf-8");
78 | return { ...DEFAULT_SETTINGS, ...JSON.parse(data) };
79 | }
80 | } catch (error) {
81 | log.warning(`⚠️ Failed to load settings: ${error}. Using defaults.`);
82 | }
83 | return { ...DEFAULT_SETTINGS };
84 | }
85 |
86 | /**
87 | * Save current settings to file
88 | */
89 | async saveSettings(newSettings: Partial<Settings>): Promise<void> {
90 | this.settings = { ...this.settings, ...newSettings };
91 | try {
92 | await fs.writeFile(this.settingsPath, JSON.stringify(this.settings, null, 2), "utf-8");
93 | } catch (error) {
94 | throw new Error(`Failed to save settings: ${error}`);
95 | }
96 | }
97 |
98 | /**
99 | * Get effective configuration (merging File settings with Env Vars)
100 | */
101 | getEffectiveSettings(): Settings {
102 | const envProfile = process.env.NOTEBOOKLM_PROFILE as ProfileName;
103 | const envDisabled = process.env.NOTEBOOKLM_DISABLED_TOOLS;
104 |
105 | const effectiveProfile = (envProfile && PROFILES[envProfile]) ? envProfile : this.settings.profile;
106 |
107 | let effectiveDisabled = [...this.settings.disabledTools];
108 | if (envDisabled) {
109 | const envDisabledList = envDisabled.split(",").map(t => t.trim());
110 | effectiveDisabled = [...new Set([...effectiveDisabled, ...envDisabledList])];
111 | }
112 |
113 | return {
114 | profile: effectiveProfile,
115 | disabledTools: effectiveDisabled,
116 | customSettings: this.settings.customSettings
117 | };
118 | }
119 |
120 | /**
121 | * Filter tools based on effective configuration
122 | */
123 | filterTools(allTools: Tool[]): Tool[] {
124 | const { profile, disabledTools } = this.getEffectiveSettings();
125 | const allowedTools = PROFILES[profile];
126 |
127 | return allTools.filter(tool => {
128 | // 1. Check if allowed by profile (unless profile is full/wildcard)
129 | if (!allowedTools.includes("*") && !allowedTools.includes(tool.name)) {
130 | return false;
131 | }
132 |
133 | // 2. Check if explicitly disabled
134 | if (disabledTools.includes(tool.name)) {
135 | return false;
136 | }
137 |
138 | return true;
139 | });
140 | }
141 |
142 | getSettingsPath(): string {
143 | return this.settingsPath;
144 | }
145 |
146 | getProfiles(): Record<ProfileName, string[]> {
147 | return PROFILES;
148 | }
149 | }
150 |
```
--------------------------------------------------------------------------------
/src/tools/definitions/notebook-management.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Tool } from "@modelcontextprotocol/sdk/types.js";
2 |
3 | export const notebookManagementTools: Tool[] = [
4 | {
5 | name: "add_notebook",
6 | description:
7 | `PERMISSION REQUIRED — Only when user explicitly asks to add a notebook.
8 |
9 | ## Conversation Workflow (Mandatory)
10 | When the user says: "I have a NotebookLM with X"
11 |
12 | 1) Ask URL: "What is the NotebookLM URL?"
13 | 2) Ask content: "What knowledge is inside?" (1–2 sentences)
14 | 3) Ask topics: "Which topics does it cover?" (3–5)
15 | 4) Ask use cases: "When should we consult it?"
16 | 5) Propose metadata and confirm:
17 | - Name: [suggested]
18 | - Description: [from user]
19 | - Topics: [list]
20 | - Use cases: [list]
21 | "Add it to your library now?"
22 | 6) Only after explicit "Yes" → call this tool
23 |
24 | ## Rules
25 | - Do not add without user permission
26 | - Do not guess metadata — ask concisely
27 | - Confirm summary before calling the tool
28 |
29 | ## Example
30 | User: "I have a notebook with n8n docs"
31 | You: Ask URL → content → topics → use cases; propose summary
32 | User: "Yes"
33 | You: Call add_notebook
34 |
35 | ## How to Get a NotebookLM Share Link
36 |
37 | Visit https://notebooklm.google/ → Login (free: 100 notebooks, 50 sources each, 500k words, 50 daily queries)
38 | 1) Click "+ New" (top right) → Upload sources (docs, knowledge)
39 | 2) Click "Share" (top right) → Select "Anyone with the link"
40 | 3) Click "Copy link" (bottom left) → Give this link to Claude
41 |
42 | (Upgraded: Google AI Pro/Ultra gives 5x higher limits)`,
43 | inputSchema: {
44 | type: "object",
45 | properties: {
46 | url: {
47 | type: "string",
48 | description: "The NotebookLM notebook URL",
49 | },
50 | name: {
51 | type: "string",
52 | description: "Display name for the notebook (e.g., 'n8n Documentation')",
53 | },
54 | description: {
55 | type: "string",
56 | description: "What knowledge/content is in this notebook",
57 | },
58 | topics: {
59 | type: "array",
60 | items: { type: "string" },
61 | description: "Topics covered in this notebook",
62 | },
63 | content_types: {
64 | type: "array",
65 | items: { type: "string" },
66 | description:
67 | "Types of content (e.g., ['documentation', 'examples', 'best practices'])",
68 | },
69 | use_cases: {
70 | type: "array",
71 | items: { type: "string" },
72 | description: "When should Claude use this notebook (e.g., ['Implementing n8n workflows'])",
73 | },
74 | tags: {
75 | type: "array",
76 | items: { type: "string" },
77 | description: "Optional tags for organization",
78 | },
79 | },
80 | required: ["url", "name", "description", "topics"],
81 | },
82 | },
83 | {
84 | name: "list_notebooks",
85 | description:
86 | "List all library notebooks with metadata (name, topics, use cases, URL). " +
87 | "Use this to present options, then ask which notebook to use for the task.",
88 | inputSchema: {
89 | type: "object",
90 | properties: {},
91 | },
92 | },
93 | {
94 | name: "get_notebook",
95 | description: "Get detailed information about a specific notebook by ID",
96 | inputSchema: {
97 | type: "object",
98 | properties: {
99 | id: {
100 | type: "string",
101 | description: "The notebook ID",
102 | },
103 | },
104 | required: ["id"],
105 | },
106 | },
107 | {
108 | name: "select_notebook",
109 | description:
110 | `Set a notebook as the active default (used when ask_question has no notebook_id).
111 |
112 | ## When To Use
113 | - User switches context: "Let's work on React now"
114 | - User asks explicitly to activate a notebook
115 | - Obvious task change requires another notebook
116 |
117 | ## Auto-Switching
118 | - Safe to auto-switch if the context is clear and you announce it:
119 | "Switching to React notebook for this task..."
120 | - If ambiguous, ask: "Switch to [notebook] for this task?"
121 |
122 | ## Example
123 | User: "Now let's build the React frontend"
124 | You: "Switching to React notebook..." (call select_notebook)`,
125 | inputSchema: {
126 | type: "object",
127 | properties: {
128 | id: {
129 | type: "string",
130 | description: "The notebook ID to activate",
131 | },
132 | },
133 | required: ["id"],
134 | },
135 | },
136 | {
137 | name: "update_notebook",
138 | description:
139 | `Update notebook metadata based on user intent.
140 |
141 | ## Pattern
142 | 1) Identify target notebook and fields (topics, description, use_cases, tags, url)
143 | 2) Propose the exact change back to the user
144 | 3) After explicit confirmation, call this tool
145 |
146 | ## Examples
147 | - User: "React notebook also covers Next.js 14"
148 | You: "Add 'Next.js 14' to topics for React?"
149 | User: "Yes" → call update_notebook
150 |
151 | - User: "Include error handling in n8n description"
152 | You: "Update the n8n description to mention error handling?"
153 | User: "Yes" → call update_notebook
154 |
155 | Tip: You may update multiple fields at once if requested.`,
156 | inputSchema: {
157 | type: "object",
158 | properties: {
159 | id: {
160 | type: "string",
161 | description: "The notebook ID to update",
162 | },
163 | name: {
164 | type: "string",
165 | description: "New display name",
166 | },
167 | description: {
168 | type: "string",
169 | description: "New description",
170 | },
171 | topics: {
172 | type: "array",
173 | items: { type: "string" },
174 | description: "New topics list",
175 | },
176 | content_types: {
177 | type: "array",
178 | items: { type: "string" },
179 | description: "New content types",
180 | },
181 | use_cases: {
182 | type: "array",
183 | items: { type: "string" },
184 | description: "New use cases",
185 | },
186 | tags: {
187 | type: "array",
188 | items: { type: "string" },
189 | description: "New tags",
190 | },
191 | url: {
192 | type: "string",
193 | description: "New notebook URL",
194 | },
195 | },
196 | required: ["id"],
197 | },
198 | },
199 | {
200 | name: "remove_notebook",
201 | description:
202 | `Dangerous — requires explicit user confirmation.
203 |
204 | ## Confirmation Workflow
205 | 1) User requests removal ("Remove the React notebook")
206 | 2) Look up full name to confirm
207 | 3) Ask: "Remove '[notebook_name]' from your library? (Does not delete the actual NotebookLM notebook)"
208 | 4) Only on explicit "Yes" → call remove_notebook
209 |
210 | Never remove without permission or based on assumptions.
211 |
212 | Example:
213 | User: "Delete the old React notebook"
214 | You: "Remove 'React Best Practices' from your library?"
215 | User: "Yes" → call remove_notebook`,
216 | inputSchema: {
217 | type: "object",
218 | properties: {
219 | id: {
220 | type: "string",
221 | description: "The notebook ID to remove",
222 | },
223 | },
224 | required: ["id"],
225 | },
226 | },
227 | {
228 | name: "search_notebooks",
229 | description:
230 | "Search library by query (name, description, topics, tags). " +
231 | "Use to propose relevant notebooks for the task and then ask which to use.",
232 | inputSchema: {
233 | type: "object",
234 | properties: {
235 | query: {
236 | type: "string",
237 | description: "Search query",
238 | },
239 | },
240 | required: ["query"],
241 | },
242 | },
243 | {
244 | name: "get_library_stats",
245 | description: "Get statistics about your notebook library (total notebooks, usage, etc.)",
246 | inputSchema: {
247 | type: "object",
248 | properties: {},
249 | },
250 | },
251 | ];
252 |
```
--------------------------------------------------------------------------------
/src/tools/definitions/system.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Tool } from "@modelcontextprotocol/sdk/types.js";
2 |
3 | export const systemTools: Tool[] = [
4 | {
5 | name: "get_health",
6 | description:
7 | "Get server health status including authentication state, active sessions, and configuration. " +
8 | "Use this to verify the server is ready before starting research workflows.\n\n" +
9 | "If authenticated=false and having persistent issues:\n" +
10 | "Consider running cleanup_data(preserve_library=true) + setup_auth for fresh start with clean browser session.",
11 | inputSchema: {
12 | type: "object",
13 | properties: {},
14 | },
15 | },
16 | {
17 | name: "setup_auth",
18 | description:
19 | "Google authentication for NotebookLM access - opens a browser window for manual login to your Google account. " +
20 | "Returns immediately after opening the browser. You have up to 10 minutes to complete the login. " +
21 | "Use 'get_health' tool afterwards to verify authentication was saved successfully. " +
22 | "Use this for first-time authentication or when auto-login credentials are not available. " +
23 | "For switching accounts or rate-limit workarounds, use 're_auth' tool instead.\n\n" +
24 | "TROUBLESHOOTING for persistent auth issues:\n" +
25 | "If setup_auth fails or you encounter browser/session issues:\n" +
26 | "1. Ask user to close ALL Chrome/Chromium instances\n" +
27 | "2. Run cleanup_data(confirm=true, preserve_library=true) to clean old data\n" +
28 | "3. Run setup_auth again for fresh start\n" +
29 | "This helps resolve conflicts from old browser sessions and installation data.",
30 | inputSchema: {
31 | type: "object",
32 | properties: {
33 | show_browser: {
34 | type: "boolean",
35 | description:
36 | "Show browser window (simple version). Default: true for setup. " +
37 | "For advanced control, use browser_options instead.",
38 | },
39 | browser_options: {
40 | type: "object",
41 | description:
42 | "Optional browser settings. Control visibility, timeouts, and stealth behavior.",
43 | properties: {
44 | show: {
45 | type: "boolean",
46 | description: "Show browser window (default: true for setup)",
47 | },
48 | headless: {
49 | type: "boolean",
50 | description: "Run browser in headless mode (default: false for setup)",
51 | },
52 | timeout_ms: {
53 | type: "number",
54 | description: "Browser operation timeout in milliseconds (default: 30000)",
55 | },
56 | },
57 | },
58 | },
59 | },
60 | },
61 | {
62 | name: "re_auth",
63 | description:
64 | "Switch to a different Google account or re-authenticate. " +
65 | "Use this when:\n" +
66 | "- NotebookLM rate limit is reached (50 queries/day for free accounts)\n" +
67 | "- You want to switch to a different Google account\n" +
68 | "- Authentication is broken and needs a fresh start\n\n" +
69 | "This will:\n" +
70 | "1. Close all active browser sessions\n" +
71 | "2. Delete all saved authentication data (cookies, Chrome profile)\n" +
72 | "3. Open browser for fresh Google login\n\n" +
73 | "After completion, use 'get_health' to verify authentication.\n\n" +
74 | "TROUBLESHOOTING for persistent auth issues:\n" +
75 | "If re_auth fails repeatedly:\n" +
76 | "1. Ask user to close ALL Chrome/Chromium instances\n" +
77 | "2. Run cleanup_data(confirm=false, preserve_library=true) to preview old files\n" +
78 | "3. Run cleanup_data(confirm=true, preserve_library=true) to clean everything except library\n" +
79 | "4. Run re_auth again for completely fresh start\n" +
80 | "This removes old installation data and browser sessions that can cause conflicts.",
81 | inputSchema: {
82 | type: "object",
83 | properties: {
84 | show_browser: {
85 | type: "boolean",
86 | description:
87 | "Show browser window (simple version). Default: true for re-auth. " +
88 | "For advanced control, use browser_options instead.",
89 | },
90 | browser_options: {
91 | type: "object",
92 | description:
93 | "Optional browser settings. Control visibility, timeouts, and stealth behavior.",
94 | properties: {
95 | show: {
96 | type: "boolean",
97 | description: "Show browser window (default: true for re-auth)",
98 | },
99 | headless: {
100 | type: "boolean",
101 | description: "Run browser in headless mode (default: false for re-auth)",
102 | },
103 | timeout_ms: {
104 | type: "number",
105 | description: "Browser operation timeout in milliseconds (default: 30000)",
106 | },
107 | },
108 | },
109 | },
110 | },
111 | },
112 | {
113 | name: "cleanup_data",
114 | description:
115 | "ULTRATHINK Deep Cleanup - Scans entire system for ALL NotebookLM MCP data files across 8 categories. Always runs in deep mode, shows categorized preview before deletion.\n\n" +
116 | "⚠️ CRITICAL: Close ALL Chrome/Chromium instances BEFORE running this tool! Open browsers can prevent cleanup and cause issues.\n\n" +
117 | "Categories scanned:\n" +
118 | "1. Legacy Installation (notebooklm-mcp-nodejs) - Old paths with -nodejs suffix\n" +
119 | "2. Current Installation (notebooklm-mcp) - Active data, browser profiles, library\n" +
120 | "3. NPM/NPX Cache - Cached installations from npx\n" +
121 | "4. Claude CLI MCP Logs - MCP server logs from Claude CLI\n" +
122 | "5. Temporary Backups - Backup directories in system temp\n" +
123 | "6. Claude Projects Cache - Project-specific cache (optional)\n" +
124 | "7. Editor Logs (Cursor/VSCode) - MCP logs from code editors (optional)\n" +
125 | "8. Trash Files - Deleted notebooklm files in system trash (optional)\n\n" +
126 | "Works cross-platform (Linux, Windows, macOS). Safe by design: shows detailed preview before deletion, requires explicit confirmation.\n\n" +
127 | "LIBRARY PRESERVATION: Set preserve_library=true to keep your notebook library.json file while cleaning everything else.\n\n" +
128 | "RECOMMENDED WORKFLOW for fresh start:\n" +
129 | "1. Ask user to close ALL Chrome/Chromium instances\n" +
130 | "2. Run cleanup_data(confirm=false, preserve_library=true) to preview\n" +
131 | "3. Run cleanup_data(confirm=true, preserve_library=true) to execute\n" +
132 | "4. Run setup_auth or re_auth for fresh browser session\n\n" +
133 | "Use cases: Clean reinstall, troubleshooting auth issues, removing all traces before uninstall, cleaning old browser sessions and installation data.",
134 | inputSchema: {
135 | type: "object",
136 | properties: {
137 | confirm: {
138 | type: "boolean",
139 | description:
140 | "Confirmation flag. Tool shows preview first, then user confirms deletion. " +
141 | "Set to true only after user has reviewed the preview and explicitly confirmed.",
142 | },
143 | preserve_library: {
144 | type: "boolean",
145 | description:
146 | "Preserve library.json file during cleanup. Default: false. " +
147 | "Set to true to keep your notebook library while deleting everything else (browser data, caches, logs).",
148 | default: false,
149 | },
150 | },
151 | required: ["confirm"],
152 | },
153 | },
154 | ];
155 |
```
--------------------------------------------------------------------------------
/docs/usage-guide.md:
--------------------------------------------------------------------------------
```markdown
1 | # Advanced Usage Guide
2 |
3 | This guide covers advanced usage patterns, best practices, and detailed examples for the NotebookLM MCP server.
4 |
5 | > 📘 For installation and quick start, see the main [README](../README.md).
6 |
7 | ## Research Patterns
8 |
9 | ### The Iterative Research Pattern
10 |
11 | The server is designed to make your agent **ask questions automatically** with NotebookLM. Here's how to leverage this:
12 |
13 | 1. **Start with broad context**
14 | ```
15 | "Before implementing the webhook system, research the complete webhook architecture in NotebookLM, including error handling, retry logic, and security considerations."
16 | ```
17 |
18 | 2. **The agent will automatically**:
19 | - Ask an initial question to NotebookLM
20 | - Read the reminder at the end of each response
21 | - Ask follow-up questions to gather more details
22 | - Continue until it has comprehensive understanding
23 | - Only then provide you with a complete answer
24 |
25 | 3. **Session management**
26 | - The agent maintains the same `session_id` throughout the research
27 | - This preserves context across multiple questions
28 | - Sessions auto-cleanup after 15 minutes of inactivity
29 |
30 | ### Deep Dive Example
31 |
32 | ```
33 | User: "I need to implement OAuth2 with refresh tokens. Research the complete flow first."
34 |
35 | Agent behavior:
36 | 1. Asks NotebookLM: "How does OAuth2 refresh token flow work?"
37 | 2. Gets answer with reminder to ask more
38 | 3. Asks: "What are the security best practices for storing refresh tokens?"
39 | 4. Asks: "How to handle token expiration and renewal?"
40 | 5. Asks: "What are common implementation pitfalls?"
41 | 6. Synthesizes all answers into comprehensive implementation plan
42 | ```
43 |
44 | ## Notebook Management Strategies
45 |
46 | ### Multi-Project Setup
47 |
48 | Organize notebooks by project or domain:
49 |
50 | ```
51 | Production Docs Notebook → APIs, deployment, monitoring
52 | Development Notebook → Local setup, debugging, testing
53 | Architecture Notebook → System design, patterns, decisions
54 | Legacy Code Notebook → Old systems, migration guides
55 | ```
56 |
57 | ### Notebook Switching Patterns
58 |
59 | ```
60 | "For this bug fix, use the Legacy Code notebook."
61 | "Switch to the Architecture notebook for this design discussion."
62 | "Use the Production Docs for deployment steps."
63 | ```
64 |
65 | ### Metadata Best Practices
66 |
67 | When adding notebooks, provide rich metadata:
68 | ```
69 | "Add this notebook with description: 'Complete React 18 documentation including hooks, performance, and migration guides' and tags: react, frontend, hooks, performance"
70 | ```
71 |
72 | ## Authentication Management
73 |
74 | ### Account Rotation Strategy
75 |
76 | Free tier provides 50 queries/day per account. Maximize usage:
77 |
78 | 1. **Primary account** → Main development work
79 | 2. **Secondary account** → Testing and validation
80 | 3. **Backup account** → Emergency queries when others are exhausted
81 |
82 | ```
83 | "Switch to secondary account" → When approaching limit
84 | "Check health status" → Verify which account is active
85 | ```
86 |
87 | ### Handling Auth Failures
88 |
89 | The agent can self-repair authentication:
90 |
91 | ```
92 | "NotebookLM says I'm logged out—repair authentication"
93 | ```
94 |
95 | This triggers: `get_health` → `setup_auth` → `get_health`
96 |
97 | ## Advanced Configuration
98 |
99 | ### Performance Optimization
100 |
101 | For faster interactions during development:
102 | ```bash
103 | STEALTH_ENABLED=false # Disable human-like typing
104 | TYPING_WPM_MAX=500 # Increase typing speed
105 | HEADLESS=false # See what's happening
106 | ```
107 |
108 | ### Debugging Sessions
109 |
110 | Enable browser visibility to watch the live conversation:
111 | ```
112 | "Research this issue and show me the browser"
113 | ```
114 |
115 | Your agent automatically enables browser visibility for that research session.
116 |
117 | ### Session Management
118 |
119 | Monitor active sessions:
120 | ```
121 | "List all active NotebookLM sessions"
122 | "Close inactive sessions to free resources"
123 | "Reset the stuck session for notebook X"
124 | ```
125 |
126 | ## Complex Workflows
127 |
128 | ### Multi-Stage Research
129 |
130 | For complex implementations requiring multiple knowledge sources:
131 |
132 | ```
133 | Stage 1: "Research the API structure in the API notebook"
134 | Stage 2: "Switch to Architecture notebook and research the service patterns"
135 | Stage 3: "Use the Security notebook to research authentication requirements"
136 | Stage 4: "Synthesize all findings into implementation plan"
137 | ```
138 |
139 | ### Validation Workflow
140 |
141 | Cross-reference information across notebooks:
142 |
143 | ```
144 | 1. "In Production notebook, find the current API version"
145 | 2. "Switch to Migration notebook, check compatibility notes"
146 | 3. "Verify in Architecture notebook if this aligns with our patterns"
147 | ```
148 |
149 | ## Tool Integration Patterns
150 |
151 | ### Direct Tool Calls
152 |
153 | For manual scripting, capture and reuse session IDs:
154 |
155 | ```json
156 | // First call - capture session_id
157 | {
158 | "tool": "ask_question",
159 | "question": "What is the webhook structure?",
160 | "notebook_id": "abc123"
161 | }
162 |
163 | // Follow-up - reuse session_id
164 | {
165 | "tool": "ask_question",
166 | "question": "Show me error handling examples",
167 | "session_id": "captured_session_id_here"
168 | }
169 | ```
170 |
171 | ### Resource URIs
172 |
173 | Access library data programmatically:
174 | - `notebooklm://library` - Full library JSON
175 | - `notebooklm://library/{id}` - Specific notebook metadata
176 |
177 | ## Best Practices
178 |
179 | ### 1. **Context Preservation**
180 | - Always let the agent complete its research cycle
181 | - Don't interrupt between questions in a research session
182 | - Use descriptive notebook names for easy switching
183 |
184 | ### 2. **Knowledge Base Quality**
185 | - Upload comprehensive documentation to NotebookLM
186 | - Merge related docs into single notebooks (up to 500k words)
187 | - Update notebooks when documentation changes
188 |
189 | ### 3. **Error Recovery**
190 | - The server auto-recovers from browser crashes
191 | - Sessions rebuild automatically if context is lost
192 | - Profile corruption triggers automatic cleanup
193 |
194 | ### 4. **Resource Management**
195 | - Close unused sessions to free memory
196 | - The server maintains max 10 concurrent sessions
197 | - Inactive sessions auto-close after 15 minutes
198 |
199 | ### 5. **Security Considerations**
200 | - Use dedicated Google accounts for NotebookLM
201 | - Never share authentication profiles between projects
202 | - Backup `library.json` for important notebook collections
203 |
204 | ## Troubleshooting Patterns
205 |
206 | ### When NotebookLM returns incomplete answers
207 | ```
208 | "The answer seems incomplete. Ask NotebookLM for more specific details about [topic]"
209 | ```
210 |
211 | ### When hitting rate limits
212 | ```
213 | "We've hit the rate limit. Re-authenticate with the backup account"
214 | ```
215 |
216 | ### When browser seems stuck
217 | ```
218 | "Reset all NotebookLM sessions and try again"
219 | ```
220 |
221 | ## Example Conversations
222 |
223 | ### Complete Feature Implementation
224 | ```
225 | User: "I need to implement a webhook system with retry logic"
226 |
227 | You: "Research webhook patterns with retry logic in NotebookLM first"
228 | Agent: [Researches comprehensively, asking 4-5 follow-up questions]
229 | Agent: "Based on my research, here's the implementation..."
230 | [Provides detailed code with patterns from NotebookLM]
231 | ```
232 |
233 | ### Architecture Decision
234 | ```
235 | User: "Should we use microservices or monolith for this feature?"
236 |
237 | You: "Research our architecture patterns and decision criteria in the Architecture notebook"
238 | Agent: [Gathers context about existing patterns, scalability needs, team constraints]
239 | Agent: "According to our architecture guidelines..."
240 | [Provides recommendation based on documented patterns]
241 | ```
242 |
243 | ---
244 |
245 | Remember: The power of this integration lies in letting your agent **ask multiple questions** – gathering context and building comprehensive understanding before responding. Don't rush the research phase!
```
--------------------------------------------------------------------------------
/src/resources/resource-handlers.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | ListResourcesRequestSchema,
3 | ListResourceTemplatesRequestSchema,
4 | ReadResourceRequestSchema,
5 | CompleteRequestSchema,
6 | } from "@modelcontextprotocol/sdk/types.js";
7 | import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
8 | import { NotebookLibrary } from "../library/notebook-library.js";
9 | import { log } from "../utils/logger.js";
10 |
11 | /**
12 | * Handlers for MCP Resource-related requests
13 | */
14 | export class ResourceHandlers {
15 | private library: NotebookLibrary;
16 |
17 | constructor(library: NotebookLibrary) {
18 | this.library = library;
19 | }
20 |
21 | /**
22 | * Register all resource handlers to the server
23 | */
24 | public registerHandlers(server: Server): void {
25 | // List available resources
26 | server.setRequestHandler(ListResourcesRequestSchema, async () => {
27 | log.info("📚 [MCP] list_resources request received");
28 |
29 | const notebooks = this.library.listNotebooks();
30 | const resources: any[] = [
31 | {
32 | uri: "notebooklm://library",
33 | name: "Notebook Library",
34 | description:
35 | "Complete notebook library with all available knowledge sources. " +
36 | "Read this to discover what notebooks are available. " +
37 | "⚠️ If you think a notebook might help with the user's task, " +
38 | "ASK THE USER FOR PERMISSION before consulting it: " +
39 | "'Should I consult the [notebook] for this task?'",
40 | mimeType: "application/json",
41 | },
42 | ];
43 |
44 | // Add individual notebook resources
45 | for (const notebook of notebooks) {
46 | resources.push({
47 | uri: `notebooklm://library/${notebook.id}`,
48 | name: notebook.name,
49 | description:
50 | `${notebook.description} | Topics: ${notebook.topics.join(", ")} | ` +
51 | `💡 Use ask_question to query this notebook (ask user permission first if task isn't explicitly about these topics)`,
52 | mimeType: "application/json",
53 | });
54 | }
55 |
56 | // Add legacy metadata resource for backwards compatibility
57 | const active = this.library.getActiveNotebook();
58 | if (active) {
59 | resources.push({
60 | uri: "notebooklm://metadata",
61 | name: "Active Notebook Metadata (Legacy)",
62 | description:
63 | "Information about the currently active notebook. " +
64 | "DEPRECATED: Use notebooklm://library instead for multi-notebook support. " +
65 | "⚠️ Always ask user permission before using notebooks for tasks they didn't explicitly mention.",
66 | mimeType: "application/json",
67 | });
68 | }
69 |
70 | return { resources };
71 | });
72 |
73 | // List resource templates
74 | server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
75 | log.info("📑 [MCP] list_resource_templates request received");
76 |
77 | return {
78 | resourceTemplates: [
79 | {
80 | uriTemplate: "notebooklm://library/{id}",
81 | name: "Notebook by ID",
82 | description:
83 | "Access a specific notebook from your library by ID. " +
84 | "Provides detailed metadata about the notebook including topics, use cases, and usage statistics. " +
85 | "💡 Use the 'id' parameter from list_notebooks to access specific notebooks.",
86 | mimeType: "application/json",
87 | },
88 | ],
89 | };
90 | });
91 |
92 | // Read resource content
93 | server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
94 | const { uri } = request.params;
95 | log.info(`📖 [MCP] read_resource request: ${uri}`);
96 |
97 | // Handle library resource
98 | if (uri === "notebooklm://library") {
99 | const notebooks = this.library.listNotebooks();
100 | const stats = this.library.getStats();
101 | const active = this.library.getActiveNotebook();
102 |
103 | const libraryData = {
104 | active_notebook: active
105 | ? {
106 | id: active.id,
107 | name: active.name,
108 | description: active.description,
109 | topics: active.topics,
110 | }
111 | : null,
112 | notebooks: notebooks.map((nb) => ({
113 | id: nb.id,
114 | name: nb.name,
115 | description: nb.description,
116 | topics: nb.topics,
117 | content_types: nb.content_types,
118 | use_cases: nb.use_cases,
119 | url: nb.url,
120 | use_count: nb.use_count,
121 | last_used: nb.last_used,
122 | tags: nb.tags,
123 | })),
124 | stats,
125 | };
126 |
127 | return {
128 | contents: [
129 | {
130 | uri,
131 | mimeType: "application/json",
132 | text: JSON.stringify(libraryData, null, 2),
133 | },
134 | ],
135 | };
136 | }
137 |
138 | // Handle individual notebook resource
139 | if (uri.startsWith("notebooklm://library/")) {
140 | const prefix = "notebooklm://library/";
141 | const encodedId = uri.slice(prefix.length);
142 | if (!encodedId) {
143 | throw new Error(
144 | "Notebook resource requires an ID (e.g. notebooklm://library/{id})"
145 | );
146 | }
147 |
148 | let id: string;
149 | try {
150 | id = decodeURIComponent(encodedId);
151 | } catch {
152 | throw new Error(`Invalid notebook identifier encoding: ${encodedId}`);
153 | }
154 |
155 | if (!/^[a-z0-9][a-z0-9-]{0,62}$/i.test(id)) {
156 | throw new Error(
157 | `Invalid notebook identifier: ${encodedId}. Notebook IDs may only contain letters, numbers, and hyphens.`
158 | );
159 | }
160 |
161 | const notebook = this.library.getNotebook(id);
162 |
163 | if (!notebook) {
164 | throw new Error(`Notebook not found: ${id}`);
165 | }
166 |
167 | return {
168 | contents: [
169 | {
170 | uri,
171 | mimeType: "application/json",
172 | text: JSON.stringify(notebook, null, 2),
173 | },
174 | ],
175 | };
176 | }
177 |
178 | // Legacy metadata resource (backwards compatibility)
179 | if (uri === "notebooklm://metadata") {
180 | const active = this.library.getActiveNotebook();
181 |
182 | if (!active) {
183 | throw new Error(
184 | "No active notebook. Use notebooklm://library to see all notebooks."
185 | );
186 | }
187 |
188 | const metadata = {
189 | description: active.description,
190 | topics: active.topics,
191 | content_types: active.content_types,
192 | use_cases: active.use_cases,
193 | notebook_url: active.url,
194 | notebook_id: active.id,
195 | last_used: active.last_used,
196 | use_count: active.use_count,
197 | note: "DEPRECATED: Use notebooklm://library or notebooklm://library/{id} instead",
198 | };
199 |
200 | return {
201 | contents: [
202 | {
203 | uri,
204 | mimeType: "application/json",
205 | text: JSON.stringify(metadata, null, 2),
206 | },
207 | ],
208 | };
209 | }
210 |
211 | throw new Error(`Unknown resource: ${uri}`);
212 | });
213 |
214 | // Argument completions (for prompt arguments and resource templates)
215 | server.setRequestHandler(CompleteRequestSchema, async (request) => {
216 | const { ref, argument } = request.params as any;
217 | try {
218 | if (ref?.type === "ref/resource") {
219 | // Complete variables for resource templates
220 | const uri = String(ref.uri || "");
221 | // Notebook by ID template
222 | if (uri === "notebooklm://library/{id}" && argument?.name === "id") {
223 | const values = this.completeNotebookIds(argument?.value);
224 | return this.buildCompletion(values) as any;
225 | }
226 | }
227 | } catch (e) {
228 | log.warning(`⚠️ [MCP] completion error: ${e}`);
229 | }
230 | return { completion: { values: [], total: 0 } } as any;
231 | });
232 | }
233 |
234 | /**
235 | * Return notebook IDs matching the provided input (case-insensitive contains)
236 | */
237 | private completeNotebookIds(input: unknown): string[] {
238 | const query = String(input ?? "").toLowerCase();
239 | return this.library
240 | .listNotebooks()
241 | .map((nb) => nb.id)
242 | .filter((id) => id.toLowerCase().includes(query))
243 | .slice(0, 50);
244 | }
245 |
246 | /**
247 | * Build a completion payload for MCP responses
248 | */
249 | private buildCompletion(values: string[]) {
250 | return {
251 | completion: {
252 | values,
253 | total: values.length,
254 | },
255 | };
256 | }
257 | }
258 |
```
--------------------------------------------------------------------------------
/src/tools/definitions/ask-question.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Tool } from "@modelcontextprotocol/sdk/types.js";
2 | import { NotebookLibrary } from "../../library/notebook-library.js";
3 |
4 | /**
5 | * Build dynamic tool description for ask_question based on active notebook or library
6 | */
7 | export function buildAskQuestionDescription(library: NotebookLibrary): string {
8 | const active = library.getActiveNotebook();
9 | const bt = "`"; // Backtick helper to avoid template literal issues
10 |
11 | if (active) {
12 | const topics = active.topics.join(", ");
13 | const useCases = active.use_cases.map((uc) => ` - ${uc}`).join("\n");
14 |
15 | return `# Conversational Research Partner (NotebookLM • Gemini 2.5 • Session RAG)
16 |
17 | **Active Notebook:** ${active.name}
18 | **Content:** ${active.description}
19 | **Topics:** ${topics}
20 |
21 | > Auth tip: If login is required, use the prompt 'notebooklm.auth-setup' and then verify with the 'get_health' tool. If authentication later fails (e.g., expired cookies), use the prompt 'notebooklm.auth-repair'.
22 |
23 | ## What This Tool Is
24 | - Full conversational research with Gemini (LLM) grounded on your notebook sources
25 | - Session-based: each follow-up uses prior context for deeper, more precise answers
26 | - Source-cited responses designed to minimize hallucinations
27 |
28 | ## When To Use
29 | ${useCases}
30 |
31 | ## Rules (Important)
32 | - Always prefer continuing an existing session for the same task
33 | - If you start a new thread, create a new session and keep its session_id
34 | - Ask clarifying questions before implementing; do not guess missing details
35 | - If multiple notebooks could apply, propose the top 1–2 and ask which to use
36 | - If task context changes, ask to reset the session or switch notebooks
37 | - If authentication fails, use the prompts 'notebooklm.auth-repair' (or 'notebooklm.auth-setup') and verify with 'get_health'
38 | - After every NotebookLM answer: pause, compare with the user's goal, and only respond if you are 100% sure the information is complete. Otherwise, plan the next NotebookLM question in the same session.
39 |
40 | ## Session Flow (Recommended)
41 | ${bt}${bt}${bt}javascript
42 | // 1) Start broad (no session_id → creates one)
43 | ask_question({ question: "Give me an overview of [topic]" })
44 | // ← Save: result.session_id
45 |
46 | // 2) Go specific (same session)
47 | ask_question({ question: "Key APIs/methods?", session_id })
48 |
49 | // 3) Cover pitfalls (same session)
50 | ask_question({ question: "Common edge cases + gotchas?", session_id })
51 |
52 | // 4) Ask for production example (same session)
53 | ask_question({ question: "Show a production-ready example", session_id })
54 | ${bt}${bt}${bt}
55 |
56 | ## Automatic Multi-Pass Strategy (Host-driven)
57 | - Simple prompts return once-and-done answers.
58 | - For complex prompts, the host should issue follow-up calls:
59 | 1. Implementation plan (APIs, dependencies, configuration, authentication).
60 | 2. Pitfalls, gaps, validation steps, missing prerequisites.
61 | - Keep the same session_id for all follow-ups, review NotebookLM's answer, and ask more questions until the problem is fully resolved.
62 | - Before replying to the user, double-check: do you truly have everything? If not, queue another ask_question immediately.
63 |
64 | ## 🔥 REAL EXAMPLE
65 |
66 | Task: "Implement error handling in n8n workflow"
67 |
68 | Bad (shallow):
69 | ${bt}${bt}${bt}
70 | Q: "How do I handle errors in n8n?"
71 | A: [basic answer]
72 | → Implement → Probably missing edge cases!
73 | ${bt}${bt}${bt}
74 |
75 | Good (deep):
76 | ${bt}${bt}${bt}
77 | Q1: "What are n8n's error handling mechanisms?" (session created)
78 | A1: [Overview of error handling]
79 |
80 | Q2: "What's the recommended pattern for API errors?" (same session)
81 | A2: [Specific patterns, uses context from Q1]
82 |
83 | Q3: "How do I handle retry logic and timeouts?" (same session)
84 | A3: [Detailed approach, builds on Q1+Q2]
85 |
86 | Q4: "Show me a production example with all these patterns" (same session)
87 | A4: [Complete example with full context]
88 |
89 | → NOW implement with confidence!
90 | ${bt}${bt}${bt}
91 |
92 | ## Notebook Selection
93 | - Default: active notebook (${active.id})
94 | - Or set notebook_id to use a library notebook
95 | - Or set notebook_url for ad-hoc notebooks (not in library)
96 | - If ambiguous which notebook fits, ASK the user which to use`;
97 | } else {
98 | return `# Conversational Research Partner (NotebookLM • Gemini 2.5 • Session RAG)
99 |
100 | ## No Active Notebook
101 | - Visit https://notebooklm.google to create a notebook and get a share link
102 | - Use **add_notebook** to add it to your library (explains how to get the link)
103 | - Use **list_notebooks** to show available sources
104 | - Use **select_notebook** to set one active
105 |
106 | > Auth tip: If login is required, use the prompt 'notebooklm.auth-setup' and then verify with the 'get_health' tool. If authentication later fails (e.g., expired cookies), use the prompt 'notebooklm.auth-repair'.
107 |
108 | Tip: Tell the user you can manage NotebookLM library and ask which notebook to use for the current task.`;
109 | }
110 | }
111 |
112 | export const askQuestionTool: Tool = {
113 | name: "ask_question",
114 | // Description will be set dynamically using buildAskQuestionDescription
115 | description: "Dynamic description placeholder",
116 | inputSchema: {
117 | type: "object",
118 | properties: {
119 | question: {
120 | type: "string",
121 | description: "The question to ask NotebookLM",
122 | },
123 | session_id: {
124 | type: "string",
125 | description:
126 | "Optional session ID for contextual conversations. If omitted, a new session is created.",
127 | },
128 | notebook_id: {
129 | type: "string",
130 | description:
131 | "Optional notebook ID from your library. If omitted, uses the active notebook. " +
132 | "Use list_notebooks to see available notebooks.",
133 | },
134 | notebook_url: {
135 | type: "string",
136 | description:
137 | "Optional notebook URL (overrides notebook_id). Use this for ad-hoc queries to notebooks not in your library.",
138 | },
139 | show_browser: {
140 | type: "boolean",
141 | description:
142 | "Show browser window for debugging (simple version). " +
143 | "For advanced control (typing speed, stealth, etc.), use browser_options instead.",
144 | },
145 | browser_options: {
146 | type: "object",
147 | description:
148 | "Optional browser behavior settings. Claude can control everything: " +
149 | "visibility, typing speed, stealth mode, timeouts. Useful for debugging or fine-tuning.",
150 | properties: {
151 | show: {
152 | type: "boolean",
153 | description: "Show browser window (default: from ENV or false)",
154 | },
155 | headless: {
156 | type: "boolean",
157 | description: "Run browser in headless mode (default: true)",
158 | },
159 | timeout_ms: {
160 | type: "number",
161 | description: "Browser operation timeout in milliseconds (default: 30000)",
162 | },
163 | stealth: {
164 | type: "object",
165 | description: "Human-like behavior settings to avoid detection",
166 | properties: {
167 | enabled: {
168 | type: "boolean",
169 | description: "Master switch for all stealth features (default: true)",
170 | },
171 | random_delays: {
172 | type: "boolean",
173 | description: "Random delays between actions (default: true)",
174 | },
175 | human_typing: {
176 | type: "boolean",
177 | description: "Human-like typing patterns (default: true)",
178 | },
179 | mouse_movements: {
180 | type: "boolean",
181 | description: "Realistic mouse movements (default: true)",
182 | },
183 | typing_wpm_min: {
184 | type: "number",
185 | description: "Minimum typing speed in WPM (default: 160)",
186 | },
187 | typing_wpm_max: {
188 | type: "number",
189 | description: "Maximum typing speed in WPM (default: 240)",
190 | },
191 | delay_min_ms: {
192 | type: "number",
193 | description: "Minimum delay between actions in ms (default: 100)",
194 | },
195 | delay_max_ms: {
196 | type: "number",
197 | description: "Maximum delay between actions in ms (default: 400)",
198 | },
199 | },
200 | },
201 | viewport: {
202 | type: "object",
203 | description: "Browser viewport size",
204 | properties: {
205 | width: {
206 | type: "number",
207 | description: "Viewport width in pixels (default: 1920)",
208 | },
209 | height: {
210 | type: "number",
211 | description: "Viewport height in pixels (default: 1080)",
212 | },
213 | },
214 | },
215 | },
216 | },
217 | },
218 | required: ["question"],
219 | },
220 | };
221 |
```
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [1.2.0] - 2025-11-21
9 |
10 | ### Added
11 | - **Tool Profiles System** - Reduce token usage by loading only the tools you need
12 | - Three profiles: `minimal` (5 tools), `standard` (10 tools), `full` (16 tools)
13 | - Persistent configuration via `~/.config/notebooklm-mcp/settings.json`
14 | - Environment variable overrides: `NOTEBOOKLM_PROFILE`, `NOTEBOOKLM_DISABLED_TOOLS`
15 |
16 | - **CLI Configuration Commands** - Easy profile management without editing files
17 | - `npx notebooklm-mcp config get` - Show current configuration
18 | - `npx notebooklm-mcp config set profile <name>` - Set profile (minimal/standard/full)
19 | - `npx notebooklm-mcp config set disabled-tools <list>` - Disable specific tools
20 | - `npx notebooklm-mcp config reset` - Reset to defaults
21 |
22 | ### Changed
23 | - **Modularized Codebase** - Improved maintainability and code organization
24 | - Split monolithic `src/tools/index.ts` into `definitions.ts` and `handlers.ts`
25 | - Extracted resource handling into dedicated `ResourceHandlers` class
26 | - Cleaner separation of concerns throughout the codebase
27 |
28 | ### Fixed
29 | - **LibreChat Compatibility** - Fixed "Server does not support completions" error
30 | - Added `prompts: {}` and `logging: {}` to server capabilities
31 | - Resolves GitHub Issue #3 for LibreChat integration
32 |
33 | - **Thinking Message Detection** - Fixed incomplete answers showing placeholder text
34 | - Now waits for `div.thinking-message` element to disappear before reading answer
35 | - Removed unreliable text-based placeholder detection (`PLACEHOLDER_SNIPPETS`)
36 | - Answers like "Reviewing the content..." or "Looking for answers..." no longer returned prematurely
37 | - Works reliably across all languages and NotebookLM UI changes
38 |
39 | ## [1.1.2] - 2025-10-19
40 |
41 | ### Changed
42 | - **README Documentation** - Added Claude Code Skill reference
43 | - New badge linking to [notebooklm-skill](https://github.com/PleasePrompto/notebooklm-skill) repository
44 | - Added prominent callout section explaining Claude Code Skill availability
45 | - Clarified differences between MCP server and Skill implementations
46 | - Added navigation link to Skill repository in top menu
47 | - Both implementations use the same browser automation technology
48 |
49 | ## [1.1.1] - 2025-10-18
50 |
51 | ### Fixed
52 | - **Binary executable permissions** - Fixed "Permission denied" error when running via npx
53 | - Added `postbuild` script that automatically runs `chmod +x dist/index.js`
54 | - Ensures binary has executable permissions after compilation
55 | - Fixes installation issue where users couldn't run the MCP server
56 |
57 | ### Repository
58 | - **Added package-lock.json** - Committed lockfile to repository for reproducible builds
59 | - Ensures consistent dependency versions across all environments
60 | - Improves contributor experience with identical development setup
61 | - Enables `npm ci` for faster, reliable installations in CI/CD
62 | - Follows npm best practices for library development (2025)
63 |
64 | ## [1.1.0] - 2025-10-18
65 |
66 | ### Added
67 | - **Deep Cleanup Tool** - Comprehensive system cleanup for fresh NotebookLM MCP installations
68 | - Scans entire system for ALL NotebookLM files (installation data, caches, logs, temp files)
69 | - Finds hidden files in NPM cache, Claude CLI logs, editor logs, system trash, temp backups
70 | - Shows categorized preview before deletion with exact file list and sizes
71 | - Safe by design: Always requires explicit confirmation after preview
72 | - Cross-platform support: Linux, Windows, macOS
73 | - Enhanced legacy path detection for old config.json files
74 | - New dependency: globby@^14.0.0 for advanced file pattern matching
75 | - CHANGELOG.md for version tracking
76 | - Changelog badge and link in README.md
77 |
78 | ### Changed
79 | - **Configuration System Simplified** - No config files needed anymore!
80 | - `config.json` completely removed - works out of the box with sensible defaults
81 | - Settings passed as tool parameters (`browser_options`) or environment variables
82 | - Claude can now control ALL browser settings via tool parameters
83 | - `saveUserConfig()` and `loadUserConfig()` functions removed
84 | - **Unified Data Paths** - Consolidated from `notebooklm-mcp-nodejs` to `notebooklm-mcp`
85 | - Linux: `~/.local/share/notebooklm-mcp/` (was: `notebooklm-mcp-nodejs`)
86 | - macOS: `~/Library/Application Support/notebooklm-mcp/`
87 | - Windows: `%LOCALAPPDATA%\notebooklm-mcp\`
88 | - Old paths automatically detected by cleanup tool
89 | - **Advanced Browser Options** - New `browser_options` parameter for browser-based tools
90 | - Control visibility, typing speed, stealth mode, timeouts, viewport size
91 | - Stealth settings: Random delays, human typing, mouse movements
92 | - Typing speed: Configurable WPM range (default: 160-240 WPM)
93 | - Delays: Configurable min/max delays (default: 100-400ms)
94 | - Viewport: Configurable size (default: 1024x768, changed from 1920x1080)
95 | - All settings optional with sensible defaults
96 | - **Default Viewport Size** - Changed from 1920x1080 to 1024x768
97 | - More reasonable default for most use cases
98 | - Can be overridden via `browser_options.viewport` parameter
99 | - Config directory (`~/.config/notebooklm-mcp/`) no longer created (not needed)
100 | - Improved logging for sessionStorage (NotebookLM does not use sessionStorage)
101 | - README.md updated to reflect config-less architecture
102 |
103 | ### Fixed
104 | - **Critical: envPaths() default suffix bug** - `env-paths` library appends `-nodejs` suffix by default
105 | - All paths were incorrectly created with `-nodejs` suffix
106 | - Fix: Explicitly pass `{suffix: ""}` to disable default behavior
107 | - Affects: `config.ts` and `cleanup-manager.ts`
108 | - Result: Correct paths now used (`notebooklm-mcp` instead of `notebooklm-mcp-nodejs`)
109 | - Enhanced cleanup tool to detect all legacy paths including manual installations
110 | - Added `getManualLegacyPaths()` method for comprehensive legacy file detection
111 | - Finds old config.json files across all platforms
112 | - Cross-platform legacy path detection (Linux XDG dirs, macOS Library, Windows AppData)
113 | - **Library Preservation Option** - cleanup_data can now preserve library.json
114 | - New parameter: `preserve_library` (default: false)
115 | - When true: Deletes everything (browser data, caches, logs) EXCEPT library.json
116 | - Perfect for clean reinstalls without losing notebook configurations
117 | - **Improved Auth Troubleshooting** - Better guidance for authentication issues
118 | - New `AuthenticationError` class with cleanup suggestions
119 | - Tool descriptions updated with troubleshooting workflows
120 | - `get_health` now returns `troubleshooting_tip` when not authenticated
121 | - Clear workflow: Close Chrome → cleanup_data(preserve_library=true) → setup_auth/re_auth
122 | - Critical warnings about closing Chrome instances before cleanup
123 | - **Critical: Browser visibility (show_browser) not working** - Fixed headless mode switching
124 | - **Root cause**: `overrideHeadless` parameter was not passed from `handleAskQuestion` to `SessionManager`
125 | - **Impact**: `show_browser=true` and `browser_options.show=true` were ignored, browser stayed headless
126 | - **Solution**:
127 | - `handleAskQuestion` now calculates and passes `overrideHeadless` parameter correctly
128 | - `SharedContextManager.getOrCreateContext()` checks for headless mode changes before reusing context
129 | - `needsHeadlessModeChange()` now checks CONFIG.headless when no override parameter provided
130 | - **Session behavior**: When browser mode changes (headless ↔ visible):
131 | - Existing session is automatically closed and recreated with same session ID
132 | - Browser context is recreated with new visibility mode
133 | - Chat history is reset (message_count returns to 0)
134 | - This is necessary because NotebookLM chat state is not persistent across browser restarts
135 | - **Files changed**: `src/tools/index.ts`, `src/session/shared-context-manager.ts`
136 |
137 | ### Removed
138 | - Empty postinstall scripts (cleaner codebase)
139 | - Deleted: `src/postinstall.ts`, `dist/postinstall.js`, type definitions
140 | - Removed: `postinstall` npm script from package.json
141 | - Follows DRY & KISS principles
142 |
143 | ## [1.0.5] - 2025-10-17
144 |
145 | ### Changed
146 | - Documentation improvements
147 | - Updated README installation instructions
148 |
149 | ## [1.0.4] - 2025-10-17
150 |
151 | ### Changed
152 | - Enhanced usage examples in documentation
153 | - Fixed formatting in usage guide
154 |
155 | ## [1.0.3] - 2025-10-16
156 |
157 | ### Changed
158 | - Improved troubleshooting guide
159 | - Added common issues and solutions
160 |
161 | ## [1.0.2] - 2025-10-16
162 |
163 | ### Fixed
164 | - Fixed typos in documentation
165 | - Clarified authentication flow
166 |
167 | ## [1.0.1] - 2025-10-16
168 |
169 | ### Changed
170 | - Enhanced README with better examples
171 | - Added more detailed setup instructions
172 |
173 | ## [1.0.0] - 2025-10-16
174 |
175 | ### Added
176 | - Initial release
177 | - NotebookLM integration via Model Context Protocol (MCP)
178 | - Session-based conversations with Gemini 2.5
179 | - Source-grounded answers from notebook documents
180 | - Notebook library management system
181 | - Google authentication with persistent browser sessions
182 | - 16 MCP tools for comprehensive NotebookLM interaction
183 | - Support for Claude Code, Codex, Cursor, and other MCP clients
184 | - TypeScript implementation with full type safety
185 | - Playwright browser automation with stealth mode
```
--------------------------------------------------------------------------------
/src/library/notebook-library.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * NotebookLM Library Manager
3 | *
4 | * Manages a persistent library of NotebookLM notebooks.
5 | * Allows Claude to autonomously add, remove, and switch between
6 | * multiple notebooks based on the task at hand.
7 | */
8 |
9 | import fs from "fs";
10 | import path from "path";
11 | import { CONFIG } from "../config.js";
12 | import { log } from "../utils/logger.js";
13 | import type {
14 | NotebookEntry,
15 | Library,
16 | AddNotebookInput,
17 | UpdateNotebookInput,
18 | LibraryStats,
19 | } from "./types.js";
20 |
21 | export class NotebookLibrary {
22 | private libraryPath: string;
23 | private library: Library;
24 |
25 | constructor() {
26 | this.libraryPath = path.join(CONFIG.dataDir, "library.json");
27 | this.library = this.loadLibrary();
28 |
29 | log.info("📚 NotebookLibrary initialized");
30 | log.info(` Library path: ${this.libraryPath}`);
31 | log.info(` Notebooks: ${this.library.notebooks.length}`);
32 | if (this.library.active_notebook_id) {
33 | log.info(` Active: ${this.library.active_notebook_id}`);
34 | }
35 | }
36 |
37 | /**
38 | * Load library from disk, or create default if not exists
39 | */
40 | private loadLibrary(): Library {
41 | try {
42 | if (fs.existsSync(this.libraryPath)) {
43 | const data = fs.readFileSync(this.libraryPath, "utf-8");
44 | const library = JSON.parse(data) as Library;
45 | log.success(` ✅ Loaded library with ${library.notebooks.length} notebooks`);
46 | return library;
47 | }
48 | } catch (error) {
49 | log.warning(` ⚠️ Failed to load library: ${error}`);
50 | }
51 |
52 | // Create default library with current CONFIG as first entry
53 | log.info(" 🆕 Creating new library...");
54 | const defaultLibrary = this.createDefaultLibrary();
55 | this.saveLibrary(defaultLibrary);
56 | return defaultLibrary;
57 | }
58 |
59 | /**
60 | * Create default library from current CONFIG
61 | */
62 | private createDefaultLibrary(): Library {
63 | const hasConfig =
64 | CONFIG.notebookUrl &&
65 | CONFIG.notebookDescription &&
66 | CONFIG.notebookDescription !== "General knowledge base - configure NOTEBOOK_DESCRIPTION to help Claude understand what's in this notebook";
67 |
68 | const notebooks: NotebookEntry[] = [];
69 |
70 | if (hasConfig) {
71 | // Create first entry from CONFIG
72 | const id = this.generateId(CONFIG.notebookDescription);
73 | notebooks.push({
74 | id,
75 | url: CONFIG.notebookUrl,
76 | name: CONFIG.notebookDescription.substring(0, 50), // First 50 chars as name
77 | description: CONFIG.notebookDescription,
78 | topics: CONFIG.notebookTopics,
79 | content_types: CONFIG.notebookContentTypes,
80 | use_cases: CONFIG.notebookUseCases,
81 | added_at: new Date().toISOString(),
82 | last_used: new Date().toISOString(),
83 | use_count: 0,
84 | tags: [],
85 | });
86 |
87 | log.success(` ✅ Created default notebook: ${id}`);
88 | }
89 |
90 | return {
91 | notebooks,
92 | active_notebook_id: notebooks.length > 0 ? notebooks[0].id : null,
93 | last_modified: new Date().toISOString(),
94 | version: "1.0.0",
95 | };
96 | }
97 |
98 | /**
99 | * Save library to disk
100 | */
101 | private saveLibrary(library: Library): void {
102 | try {
103 | library.last_modified = new Date().toISOString();
104 | const data = JSON.stringify(library, null, 2);
105 | fs.writeFileSync(this.libraryPath, data, "utf-8");
106 | this.library = library;
107 | log.success(` 💾 Library saved (${library.notebooks.length} notebooks)`);
108 | } catch (error) {
109 | log.error(` ❌ Failed to save library: ${error}`);
110 | throw error;
111 | }
112 | }
113 |
114 | /**
115 | * Generate a unique ID from a string (slug format)
116 | */
117 | private generateId(name: string): string {
118 | const base = name
119 | .toLowerCase()
120 | .replace(/[^a-z0-9]+/g, "-")
121 | .replace(/^-+|-+$/g, "")
122 | .substring(0, 30);
123 |
124 | // Ensure uniqueness
125 | let id = base;
126 | let counter = 1;
127 | while (this.library.notebooks.some((n) => n.id === id)) {
128 | id = `${base}-${counter}`;
129 | counter++;
130 | }
131 |
132 | return id;
133 | }
134 |
135 | /**
136 | * Add a new notebook to the library
137 | */
138 | addNotebook(input: AddNotebookInput): NotebookEntry {
139 | log.info(`📝 Adding notebook: ${input.name}`);
140 |
141 | // Generate ID
142 | const id = this.generateId(input.name);
143 |
144 | // Create entry
145 | const notebook: NotebookEntry = {
146 | id,
147 | url: input.url,
148 | name: input.name,
149 | description: input.description,
150 | topics: input.topics,
151 | content_types: input.content_types || ["documentation", "examples"],
152 | use_cases: input.use_cases || [
153 | `Learning about ${input.name}`,
154 | `Implementing features with ${input.name}`,
155 | ],
156 | added_at: new Date().toISOString(),
157 | last_used: new Date().toISOString(),
158 | use_count: 0,
159 | tags: input.tags || [],
160 | };
161 |
162 | // Add to library
163 | const updated = { ...this.library };
164 | updated.notebooks.push(notebook);
165 |
166 | // Set as active if it's the first notebook
167 | if (updated.notebooks.length === 1) {
168 | updated.active_notebook_id = id;
169 | }
170 |
171 | this.saveLibrary(updated);
172 | log.success(`✅ Notebook added: ${id}`);
173 |
174 | return notebook;
175 | }
176 |
177 | /**
178 | * List all notebooks in library
179 | */
180 | listNotebooks(): NotebookEntry[] {
181 | return this.library.notebooks;
182 | }
183 |
184 | /**
185 | * Get a specific notebook by ID
186 | */
187 | getNotebook(id: string): NotebookEntry | null {
188 | return this.library.notebooks.find((n) => n.id === id) || null;
189 | }
190 |
191 | /**
192 | * Get the currently active notebook
193 | */
194 | getActiveNotebook(): NotebookEntry | null {
195 | if (!this.library.active_notebook_id) {
196 | return null;
197 | }
198 | return this.getNotebook(this.library.active_notebook_id);
199 | }
200 |
201 | /**
202 | * Select a notebook as active
203 | */
204 | selectNotebook(id: string): NotebookEntry {
205 | const notebook = this.getNotebook(id);
206 | if (!notebook) {
207 | throw new Error(`Notebook not found: ${id}`);
208 | }
209 |
210 | log.info(`🎯 Selecting notebook: ${id}`);
211 |
212 | const updated = { ...this.library };
213 | updated.active_notebook_id = id;
214 |
215 | // Update last_used
216 | const notebookIndex = updated.notebooks.findIndex((n) => n.id === id);
217 | updated.notebooks[notebookIndex] = {
218 | ...notebook,
219 | last_used: new Date().toISOString(),
220 | };
221 |
222 | this.saveLibrary(updated);
223 | log.success(`✅ Active notebook: ${id}`);
224 |
225 | return updated.notebooks[notebookIndex];
226 | }
227 |
228 | /**
229 | * Update notebook metadata
230 | */
231 | updateNotebook(input: UpdateNotebookInput): NotebookEntry {
232 | const notebook = this.getNotebook(input.id);
233 | if (!notebook) {
234 | throw new Error(`Notebook not found: ${input.id}`);
235 | }
236 |
237 | log.info(`📝 Updating notebook: ${input.id}`);
238 |
239 | const updated = { ...this.library };
240 | const index = updated.notebooks.findIndex((n) => n.id === input.id);
241 |
242 | updated.notebooks[index] = {
243 | ...notebook,
244 | ...(input.name && { name: input.name }),
245 | ...(input.description && { description: input.description }),
246 | ...(input.topics && { topics: input.topics }),
247 | ...(input.content_types && { content_types: input.content_types }),
248 | ...(input.use_cases && { use_cases: input.use_cases }),
249 | ...(input.tags && { tags: input.tags }),
250 | ...(input.url && { url: input.url }),
251 | };
252 |
253 | this.saveLibrary(updated);
254 | log.success(`✅ Notebook updated: ${input.id}`);
255 |
256 | return updated.notebooks[index];
257 | }
258 |
259 | /**
260 | * Remove notebook from library
261 | */
262 | removeNotebook(id: string): boolean {
263 | const notebook = this.getNotebook(id);
264 | if (!notebook) {
265 | return false;
266 | }
267 |
268 | log.info(`🗑️ Removing notebook: ${id}`);
269 |
270 | const updated = { ...this.library };
271 | updated.notebooks = updated.notebooks.filter((n) => n.id !== id);
272 |
273 | // If we removed the active notebook, select another one
274 | if (updated.active_notebook_id === id) {
275 | updated.active_notebook_id =
276 | updated.notebooks.length > 0 ? updated.notebooks[0].id : null;
277 | }
278 |
279 | this.saveLibrary(updated);
280 | log.success(`✅ Notebook removed: ${id}`);
281 |
282 | return true;
283 | }
284 |
285 | /**
286 | * Increment use count for a notebook
287 | */
288 | incrementUseCount(id: string): NotebookEntry | null {
289 | const notebookIndex = this.library.notebooks.findIndex((n) => n.id === id);
290 | if (notebookIndex === -1) {
291 | return null;
292 | }
293 |
294 | const notebook = this.library.notebooks[notebookIndex];
295 | const updated = { ...this.library };
296 | const updatedNotebook: NotebookEntry = {
297 | ...notebook,
298 | use_count: notebook.use_count + 1,
299 | last_used: new Date().toISOString(),
300 | };
301 |
302 | updated.notebooks[notebookIndex] = updatedNotebook;
303 | this.saveLibrary(updated);
304 |
305 | return updatedNotebook;
306 | }
307 |
308 | /**
309 | * Get library statistics
310 | */
311 | getStats(): LibraryStats {
312 | const totalQueries = this.library.notebooks.reduce(
313 | (sum, n) => sum + n.use_count,
314 | 0
315 | );
316 |
317 | const mostUsed = this.library.notebooks.reduce((max, n) =>
318 | n.use_count > (max?.use_count || 0) ? n : max
319 | , null as NotebookEntry | null);
320 |
321 | return {
322 | total_notebooks: this.library.notebooks.length,
323 | active_notebook: this.library.active_notebook_id,
324 | most_used_notebook: mostUsed?.id || null,
325 | total_queries: totalQueries,
326 | last_modified: this.library.last_modified,
327 | };
328 | }
329 |
330 | /**
331 | * Search notebooks by query (searches name, description, topics)
332 | */
333 | searchNotebooks(query: string): NotebookEntry[] {
334 | const lowerQuery = query.toLowerCase();
335 | return this.library.notebooks.filter(
336 | (n) =>
337 | n.name.toLowerCase().includes(lowerQuery) ||
338 | n.description.toLowerCase().includes(lowerQuery) ||
339 | n.topics.some((t) => t.toLowerCase().includes(lowerQuery)) ||
340 | n.tags?.some((t) => t.toLowerCase().includes(lowerQuery))
341 | );
342 | }
343 | }
344 |
```
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Configuration for NotebookLM MCP Server
3 | *
4 | * Config Priority (highest to lowest):
5 | * 1. Hardcoded Defaults (works out of the box!)
6 | * 2. Environment Variables (optional, for advanced users)
7 | * 3. Tool Parameters (passed by Claude at runtime)
8 | *
9 | * No config.json file needed - all settings via ENV or tool parameters!
10 | */
11 |
12 | import envPaths from "env-paths";
13 | import fs from "fs";
14 | import path from "path";
15 |
16 | // Cross-platform data paths (unified without -nodejs suffix)
17 | // Linux: ~/.local/share/notebooklm-mcp/
18 | // macOS: ~/Library/Application Support/notebooklm-mcp/
19 | // Windows: %APPDATA%\notebooklm-mcp\
20 | // IMPORTANT: Pass empty string suffix to disable envPaths' default '-nodejs' suffix!
21 | const paths = envPaths("notebooklm-mcp", {suffix: ""});
22 |
23 | /**
24 | * Google NotebookLM Auth URL (used by setup_auth)
25 | * This is the base Google login URL that redirects to NotebookLM
26 | */
27 | export const NOTEBOOKLM_AUTH_URL =
28 | "https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fnotebooklm.google.com%2F&flowName=GlifWebSignIn&flowEntry=ServiceLogin";
29 |
30 | export interface Config {
31 | // NotebookLM - optional, used for legacy default notebook
32 | notebookUrl: string;
33 |
34 | // Browser Settings
35 | headless: boolean;
36 | browserTimeout: number;
37 | viewport: { width: number; height: number };
38 |
39 | // Session Management
40 | maxSessions: number;
41 | sessionTimeout: number; // in seconds
42 |
43 | // Authentication
44 | autoLoginEnabled: boolean;
45 | loginEmail: string;
46 | loginPassword: string;
47 | autoLoginTimeoutMs: number;
48 |
49 | // Stealth Settings
50 | stealthEnabled: boolean;
51 | stealthRandomDelays: boolean;
52 | stealthHumanTyping: boolean;
53 | stealthMouseMovements: boolean;
54 | typingWpmMin: number;
55 | typingWpmMax: number;
56 | minDelayMs: number;
57 | maxDelayMs: number;
58 |
59 | // Paths
60 | configDir: string;
61 | dataDir: string;
62 | browserStateDir: string;
63 | chromeProfileDir: string;
64 | chromeInstancesDir: string;
65 |
66 | // Library Configuration (optional, for default notebook metadata)
67 | notebookDescription: string;
68 | notebookTopics: string[];
69 | notebookContentTypes: string[];
70 | notebookUseCases: string[];
71 |
72 | // Multi-instance profile strategy
73 | profileStrategy: "auto" | "single" | "isolated";
74 | cloneProfileOnIsolated: boolean;
75 | cleanupInstancesOnStartup: boolean;
76 | cleanupInstancesOnShutdown: boolean;
77 | instanceProfileTtlHours: number;
78 | instanceProfileMaxCount: number;
79 | }
80 |
81 | /**
82 | * Default Configuration (works out of the box!)
83 | */
84 | const DEFAULTS: Config = {
85 | // NotebookLM
86 | notebookUrl: "",
87 |
88 | // Browser Settings
89 | headless: true,
90 | browserTimeout: 30000,
91 | viewport: { width: 1024, height: 768 },
92 |
93 | // Session Management
94 | maxSessions: 10,
95 | sessionTimeout: 900, // 15 minutes
96 |
97 | // Authentication
98 | autoLoginEnabled: false,
99 | loginEmail: "",
100 | loginPassword: "",
101 | autoLoginTimeoutMs: 120000, // 2 minutes
102 |
103 | // Stealth Settings
104 | stealthEnabled: true,
105 | stealthRandomDelays: true,
106 | stealthHumanTyping: true,
107 | stealthMouseMovements: true,
108 | typingWpmMin: 160,
109 | typingWpmMax: 240,
110 | minDelayMs: 100,
111 | maxDelayMs: 400,
112 |
113 | // Paths (cross-platform via env-paths)
114 | configDir: paths.config,
115 | dataDir: paths.data,
116 | browserStateDir: path.join(paths.data, "browser_state"),
117 | chromeProfileDir: path.join(paths.data, "chrome_profile"),
118 | chromeInstancesDir: path.join(paths.data, "chrome_profile_instances"),
119 |
120 | // Library Configuration
121 | notebookDescription: "General knowledge base",
122 | notebookTopics: ["General topics"],
123 | notebookContentTypes: ["documentation", "examples"],
124 | notebookUseCases: ["General research"],
125 |
126 | // Multi-instance strategy
127 | profileStrategy: "auto",
128 | cloneProfileOnIsolated: false,
129 | cleanupInstancesOnStartup: true,
130 | cleanupInstancesOnShutdown: true,
131 | instanceProfileTtlHours: 72,
132 | instanceProfileMaxCount: 20,
133 | };
134 |
135 |
136 | /**
137 | * Parse boolean from string (for env vars)
138 | */
139 | function parseBoolean(value: string | undefined, defaultValue: boolean): boolean {
140 | if (value === undefined) return defaultValue;
141 | const lower = value.toLowerCase();
142 | if (lower === "true" || lower === "1") return true;
143 | if (lower === "false" || lower === "0") return false;
144 | return defaultValue;
145 | }
146 |
147 | /**
148 | * Parse integer from string (for env vars)
149 | */
150 | function parseInteger(value: string | undefined, defaultValue: number): number {
151 | if (value === undefined) return defaultValue;
152 | const parsed = Number.parseInt(value, 10);
153 | return Number.isNaN(parsed) ? defaultValue : parsed;
154 | }
155 |
156 | /**
157 | * Parse comma-separated array (for env vars)
158 | */
159 | function parseArray(value: string | undefined, defaultValue: string[]): string[] {
160 | if (!value) return defaultValue;
161 | return value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
162 | }
163 |
164 | /**
165 | * Apply environment variable overrides (legacy support)
166 | */
167 | function applyEnvOverrides(config: Config): Config {
168 | return {
169 | ...config,
170 | // Override with env vars if present
171 | notebookUrl: process.env.NOTEBOOK_URL || config.notebookUrl,
172 | headless: parseBoolean(process.env.HEADLESS, config.headless),
173 | browserTimeout: parseInteger(process.env.BROWSER_TIMEOUT, config.browserTimeout),
174 | maxSessions: parseInteger(process.env.MAX_SESSIONS, config.maxSessions),
175 | sessionTimeout: parseInteger(process.env.SESSION_TIMEOUT, config.sessionTimeout),
176 | autoLoginEnabled: parseBoolean(process.env.AUTO_LOGIN_ENABLED, config.autoLoginEnabled),
177 | loginEmail: process.env.LOGIN_EMAIL || config.loginEmail,
178 | loginPassword: process.env.LOGIN_PASSWORD || config.loginPassword,
179 | autoLoginTimeoutMs: parseInteger(process.env.AUTO_LOGIN_TIMEOUT_MS, config.autoLoginTimeoutMs),
180 | stealthEnabled: parseBoolean(process.env.STEALTH_ENABLED, config.stealthEnabled),
181 | stealthRandomDelays: parseBoolean(process.env.STEALTH_RANDOM_DELAYS, config.stealthRandomDelays),
182 | stealthHumanTyping: parseBoolean(process.env.STEALTH_HUMAN_TYPING, config.stealthHumanTyping),
183 | stealthMouseMovements: parseBoolean(process.env.STEALTH_MOUSE_MOVEMENTS, config.stealthMouseMovements),
184 | typingWpmMin: parseInteger(process.env.TYPING_WPM_MIN, config.typingWpmMin),
185 | typingWpmMax: parseInteger(process.env.TYPING_WPM_MAX, config.typingWpmMax),
186 | minDelayMs: parseInteger(process.env.MIN_DELAY_MS, config.minDelayMs),
187 | maxDelayMs: parseInteger(process.env.MAX_DELAY_MS, config.maxDelayMs),
188 | notebookDescription: process.env.NOTEBOOK_DESCRIPTION || config.notebookDescription,
189 | notebookTopics: parseArray(process.env.NOTEBOOK_TOPICS, config.notebookTopics),
190 | notebookContentTypes: parseArray(process.env.NOTEBOOK_CONTENT_TYPES, config.notebookContentTypes),
191 | notebookUseCases: parseArray(process.env.NOTEBOOK_USE_CASES, config.notebookUseCases),
192 | profileStrategy: (process.env.NOTEBOOK_PROFILE_STRATEGY as any) || config.profileStrategy,
193 | cloneProfileOnIsolated: parseBoolean(process.env.NOTEBOOK_CLONE_PROFILE, config.cloneProfileOnIsolated),
194 | cleanupInstancesOnStartup: parseBoolean(process.env.NOTEBOOK_CLEANUP_ON_STARTUP, config.cleanupInstancesOnStartup),
195 | cleanupInstancesOnShutdown: parseBoolean(process.env.NOTEBOOK_CLEANUP_ON_SHUTDOWN, config.cleanupInstancesOnShutdown),
196 | instanceProfileTtlHours: parseInteger(process.env.NOTEBOOK_INSTANCE_TTL_HOURS, config.instanceProfileTtlHours),
197 | instanceProfileMaxCount: parseInteger(process.env.NOTEBOOK_INSTANCE_MAX_COUNT, config.instanceProfileMaxCount),
198 | };
199 | }
200 |
201 | /**
202 | * Build final configuration
203 | * Priority: Defaults → Environment Variables → Tool Parameters (at runtime)
204 | * No config.json files - everything via ENV or tool parameters!
205 | */
206 | function buildConfig(): Config {
207 | return applyEnvOverrides(DEFAULTS);
208 | }
209 |
210 | /**
211 | * Global configuration instance
212 | */
213 | export const CONFIG: Config = buildConfig();
214 |
215 | /**
216 | * Ensure all required directories exist
217 | * NOTE: We do NOT create configDir - it's not needed!
218 | */
219 | export function ensureDirectories(): void {
220 | const dirs = [
221 | CONFIG.dataDir,
222 | CONFIG.browserStateDir,
223 | CONFIG.chromeProfileDir,
224 | CONFIG.chromeInstancesDir,
225 | ];
226 |
227 | for (const dir of dirs) {
228 | if (!fs.existsSync(dir)) {
229 | fs.mkdirSync(dir, { recursive: true });
230 | }
231 | }
232 | }
233 |
234 |
235 | /**
236 | * Browser options that can be passed via tool parameters
237 | */
238 | export interface BrowserOptions {
239 | show?: boolean;
240 | headless?: boolean;
241 | timeout_ms?: number;
242 | stealth?: {
243 | enabled?: boolean;
244 | random_delays?: boolean;
245 | human_typing?: boolean;
246 | mouse_movements?: boolean;
247 | typing_wpm_min?: number;
248 | typing_wpm_max?: number;
249 | delay_min_ms?: number;
250 | delay_max_ms?: number;
251 | };
252 | viewport?: {
253 | width?: number;
254 | height?: number;
255 | };
256 | }
257 |
258 | /**
259 | * Apply browser options to CONFIG (returns modified copy, doesn't mutate global CONFIG)
260 | */
261 | export function applyBrowserOptions(
262 | options?: BrowserOptions,
263 | legacyShowBrowser?: boolean
264 | ): Config {
265 | const config = { ...CONFIG };
266 |
267 | // Handle legacy show_browser parameter
268 | if (legacyShowBrowser !== undefined) {
269 | config.headless = !legacyShowBrowser;
270 | }
271 |
272 | // Apply browser_options (takes precedence over legacy parameter)
273 | if (options) {
274 | if (options.show !== undefined) {
275 | config.headless = !options.show;
276 | }
277 | if (options.headless !== undefined) {
278 | config.headless = options.headless;
279 | }
280 | if (options.timeout_ms !== undefined) {
281 | config.browserTimeout = options.timeout_ms;
282 | }
283 | if (options.stealth) {
284 | const s = options.stealth;
285 | if (s.enabled !== undefined) config.stealthEnabled = s.enabled;
286 | if (s.random_delays !== undefined) config.stealthRandomDelays = s.random_delays;
287 | if (s.human_typing !== undefined) config.stealthHumanTyping = s.human_typing;
288 | if (s.mouse_movements !== undefined) config.stealthMouseMovements = s.mouse_movements;
289 | if (s.typing_wpm_min !== undefined) config.typingWpmMin = s.typing_wpm_min;
290 | if (s.typing_wpm_max !== undefined) config.typingWpmMax = s.typing_wpm_max;
291 | if (s.delay_min_ms !== undefined) config.minDelayMs = s.delay_min_ms;
292 | if (s.delay_max_ms !== undefined) config.maxDelayMs = s.delay_max_ms;
293 | }
294 | if (options.viewport) {
295 | config.viewport = {
296 | width: options.viewport.width ?? config.viewport.width,
297 | height: options.viewport.height ?? config.viewport.height,
298 | };
299 | }
300 | }
301 |
302 | return config;
303 | }
304 |
305 | // Create directories on import
306 | ensureDirectories();
307 |
```
--------------------------------------------------------------------------------
/src/session/session-manager.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Session Manager
3 | *
4 | * Manages multiple parallel browser sessions for NotebookLM API
5 | *
6 | * Features:
7 | * - Session lifecycle management
8 | * - Auto-cleanup of inactive sessions
9 | * - Resource limits (max concurrent sessions)
10 | * - Shared PERSISTENT browser fingerprint (ONE context for all sessions)
11 | *
12 | * Based on the Python implementation from session_manager.py
13 | */
14 |
15 | import { AuthManager } from "../auth/auth-manager.js";
16 | import { BrowserSession } from "./browser-session.js";
17 | import { SharedContextManager } from "./shared-context-manager.js";
18 | import { CONFIG } from "../config.js";
19 | import { log } from "../utils/logger.js";
20 | import type { SessionInfo } from "../types.js";
21 | import { randomBytes } from "crypto";
22 |
23 | export class SessionManager {
24 | private authManager: AuthManager;
25 | private sharedContextManager: SharedContextManager;
26 | private sessions: Map<string, BrowserSession> = new Map();
27 | private maxSessions: number;
28 | private sessionTimeout: number;
29 | private cleanupInterval?: NodeJS.Timeout;
30 |
31 | constructor(authManager: AuthManager) {
32 | this.authManager = authManager;
33 | this.sharedContextManager = new SharedContextManager(authManager);
34 | this.maxSessions = CONFIG.maxSessions;
35 | this.sessionTimeout = CONFIG.sessionTimeout;
36 |
37 | log.info("🎯 SessionManager initialized");
38 | log.info(` Max sessions: ${this.maxSessions}`);
39 | log.info(
40 | ` Timeout: ${this.sessionTimeout}s (${Math.floor(this.sessionTimeout / 60)} minutes)`
41 | );
42 |
43 | const cleanupIntervalSeconds = Math.max(
44 | 60,
45 | Math.min(Math.floor(this.sessionTimeout / 2), 300)
46 | );
47 | this.cleanupInterval = setInterval(() => {
48 | this.cleanupInactiveSessions().catch((error) => {
49 | log.warning(`⚠️ Error during automatic session cleanup: ${error}`);
50 | });
51 | }, cleanupIntervalSeconds * 1000);
52 | this.cleanupInterval.unref();
53 | }
54 |
55 | /**
56 | * Generate a unique session ID
57 | */
58 | private generateSessionId(): string {
59 | return randomBytes(4).toString("hex");
60 | }
61 |
62 | /**
63 | * Get existing session or create a new one
64 | *
65 | * @param sessionId Optional session ID to reuse existing session
66 | * @param notebookUrl Notebook URL for the session
67 | * @param overrideHeadless Optional override for headless mode (true = show browser)
68 | */
69 | async getOrCreateSession(
70 | sessionId?: string,
71 | notebookUrl?: string,
72 | overrideHeadless?: boolean
73 | ): Promise<BrowserSession> {
74 | // Determine target notebook URL
75 | const targetUrl = (notebookUrl || CONFIG.notebookUrl || "").trim();
76 | if (!targetUrl) {
77 | throw new Error("Notebook URL is required to create a session");
78 | }
79 | if (!targetUrl.startsWith("http")) {
80 | throw new Error("Notebook URL must be an absolute URL");
81 | }
82 |
83 | // Generate ID if not provided
84 | if (!sessionId) {
85 | sessionId = this.generateSessionId();
86 | log.info(`🆕 Auto-generated session ID: ${sessionId}`);
87 | }
88 |
89 | // Check if browser visibility mode needs to change
90 | if (overrideHeadless !== undefined) {
91 | if (this.sharedContextManager.needsHeadlessModeChange(overrideHeadless)) {
92 | log.warning(`🔄 Browser visibility changed - closing all sessions to recreate browser context...`);
93 | const currentMode = this.sharedContextManager.getCurrentHeadlessMode();
94 | log.info(` Switching from ${currentMode ? 'HEADLESS' : 'VISIBLE'} to ${overrideHeadless ? 'VISIBLE' : 'HEADLESS'}`);
95 |
96 | // Close all sessions (they all use the same context)
97 | await this.closeAllSessions();
98 | log.success(` ✅ All sessions closed, browser context will be recreated with new mode`);
99 | }
100 | }
101 |
102 | // Return existing session if found
103 | if (this.sessions.has(sessionId)) {
104 | const session = this.sessions.get(sessionId)!;
105 | if (session.notebookUrl !== targetUrl) {
106 | log.warning(`♻️ Replacing session ${sessionId} with new notebook URL`);
107 | await session.close();
108 | this.sessions.delete(sessionId);
109 | } else {
110 | session.updateActivity();
111 | log.success(`♻️ Reusing existing session ${sessionId}`);
112 | return session;
113 | }
114 | }
115 |
116 | // Check if we need to free up space
117 | if (this.sessions.size >= this.maxSessions) {
118 | log.warning(`⚠️ Max sessions (${this.maxSessions}) reached, cleaning up...`);
119 | const freed = await this.cleanupOldestSession();
120 | if (!freed) {
121 | throw new Error(
122 | `Max sessions (${this.maxSessions}) reached and no inactive sessions to clean up`
123 | );
124 | }
125 | }
126 |
127 | // Create new session
128 | log.info(`🆕 Creating new session ${sessionId}...`);
129 | if (overrideHeadless !== undefined) {
130 | log.info(` Show browser: ${overrideHeadless}`);
131 | }
132 | try {
133 | // Ensure the shared context exists (ONE fingerprint for all sessions!)
134 | await this.sharedContextManager.getOrCreateContext(overrideHeadless);
135 |
136 | // Create and initialize session
137 | const session = new BrowserSession(
138 | sessionId,
139 | this.sharedContextManager,
140 | this.authManager,
141 | targetUrl
142 | );
143 | await session.init();
144 |
145 | this.sessions.set(sessionId, session);
146 | log.success(
147 | `✅ Session ${sessionId} created (${this.sessions.size}/${this.maxSessions} active)`
148 | );
149 | return session;
150 | } catch (error) {
151 | log.error(`❌ Failed to create session: ${error}`);
152 | throw error;
153 | }
154 | }
155 |
156 | /**
157 | * Get an existing session by ID
158 | */
159 | getSession(sessionId: string): BrowserSession | null {
160 | return this.sessions.get(sessionId) || null;
161 | }
162 |
163 | /**
164 | * Close and remove a specific session
165 | */
166 | async closeSession(sessionId: string): Promise<boolean> {
167 | if (!this.sessions.has(sessionId)) {
168 | log.warning(`⚠️ Session ${sessionId} not found`);
169 | return false;
170 | }
171 |
172 | const session = this.sessions.get(sessionId)!;
173 | await session.close();
174 | this.sessions.delete(sessionId);
175 |
176 | log.success(
177 | `✅ Session ${sessionId} closed (${this.sessions.size}/${this.maxSessions} active)`
178 | );
179 | return true;
180 | }
181 |
182 | /**
183 | * Close all sessions that are using the provided notebook URL
184 | */
185 | async closeSessionsForNotebook(url: string): Promise<number> {
186 | let closed = 0;
187 |
188 | for (const [sessionId, session] of Array.from(this.sessions.entries())) {
189 | if (session.notebookUrl === url) {
190 | try {
191 | await session.close();
192 | } catch (error) {
193 | log.warning(` ⚠️ Error closing ${sessionId}: ${error}`);
194 | } finally {
195 | this.sessions.delete(sessionId);
196 | closed++;
197 | }
198 | }
199 | }
200 |
201 | if (closed > 0) {
202 | log.warning(
203 | `🧹 Closed ${closed} session(s) using removed notebook (${this.sessions.size}/${this.maxSessions} active)`
204 | );
205 | }
206 |
207 | return closed;
208 | }
209 |
210 | /**
211 | * Clean up all inactive sessions
212 | */
213 | async cleanupInactiveSessions(): Promise<number> {
214 | const inactiveSessions: string[] = [];
215 |
216 | for (const [sessionId, session] of this.sessions.entries()) {
217 | if (session.isExpired(this.sessionTimeout)) {
218 | inactiveSessions.push(sessionId);
219 | }
220 | }
221 |
222 | if (inactiveSessions.length === 0) {
223 | return 0;
224 | }
225 |
226 | log.warning(`🧹 Cleaning up ${inactiveSessions.length} inactive sessions...`);
227 |
228 | for (const sessionId of inactiveSessions) {
229 | try {
230 | const session = this.sessions.get(sessionId)!;
231 | const age = (Date.now() - session.createdAt) / 1000;
232 | const inactive = (Date.now() - session.lastActivity) / 1000;
233 |
234 | log.warning(
235 | ` 🗑️ ${sessionId}: age=${age.toFixed(0)}s, inactive=${inactive.toFixed(0)}s, messages=${session.messageCount}`
236 | );
237 |
238 | await session.close();
239 | this.sessions.delete(sessionId);
240 | } catch (error) {
241 | log.warning(` ⚠️ Error cleaning up ${sessionId}: ${error}`);
242 | }
243 | }
244 |
245 | log.success(
246 | `✅ Cleaned up ${inactiveSessions.length} sessions (${this.sessions.size}/${this.maxSessions} active)`
247 | );
248 | return inactiveSessions.length;
249 | }
250 |
251 | /**
252 | * Clean up the oldest session to make space
253 | */
254 | private async cleanupOldestSession(): Promise<boolean> {
255 | if (this.sessions.size === 0) {
256 | return false;
257 | }
258 |
259 | // Find oldest session
260 | let oldestId: string | null = null;
261 | let oldestTime = Infinity;
262 |
263 | for (const [sessionId, session] of this.sessions.entries()) {
264 | if (session.createdAt < oldestTime) {
265 | oldestTime = session.createdAt;
266 | oldestId = sessionId;
267 | }
268 | }
269 |
270 | if (!oldestId) {
271 | return false;
272 | }
273 |
274 | const oldestSession = this.sessions.get(oldestId)!;
275 | const age = (Date.now() - oldestSession.createdAt) / 1000;
276 |
277 | log.warning(`🗑️ Removing oldest session ${oldestId} (age: ${age.toFixed(0)}s)`);
278 |
279 | await oldestSession.close();
280 | this.sessions.delete(oldestId);
281 |
282 | return true;
283 | }
284 |
285 | /**
286 | * Close all sessions (used during shutdown)
287 | */
288 | async closeAllSessions(): Promise<void> {
289 | if (this.cleanupInterval) {
290 | clearInterval(this.cleanupInterval);
291 | this.cleanupInterval = undefined;
292 | }
293 |
294 | if (this.sessions.size === 0) {
295 | log.warning("🛑 Closing shared context (no active sessions)...");
296 | await this.sharedContextManager.closeContext();
297 | log.success("✅ All sessions closed");
298 | return;
299 | }
300 |
301 | log.warning(`🛑 Closing all ${this.sessions.size} sessions...`);
302 |
303 | for (const sessionId of Array.from(this.sessions.keys())) {
304 | try {
305 | const session = this.sessions.get(sessionId)!;
306 | await session.close();
307 | this.sessions.delete(sessionId);
308 | } catch (error) {
309 | log.warning(` ⚠️ Error closing ${sessionId}: ${error}`);
310 | }
311 | }
312 |
313 | // Close the shared context
314 | await this.sharedContextManager.closeContext();
315 |
316 | log.success("✅ All sessions closed");
317 | }
318 |
319 | /**
320 | * Get all sessions info
321 | */
322 | getAllSessionsInfo(): SessionInfo[] {
323 | return Array.from(this.sessions.values()).map((session) => session.getInfo());
324 | }
325 |
326 | /**
327 | * Get aggregate stats
328 | */
329 | getStats(): {
330 | active_sessions: number;
331 | max_sessions: number;
332 | session_timeout: number;
333 | oldest_session_seconds: number;
334 | total_messages: number;
335 | } {
336 | const sessionsInfo = this.getAllSessionsInfo();
337 |
338 | const totalMessages = sessionsInfo.reduce(
339 | (sum, info) => sum + info.message_count,
340 | 0
341 | );
342 | const oldestSessionSeconds = Math.max(
343 | ...sessionsInfo.map((info) => info.age_seconds),
344 | 0
345 | );
346 |
347 | return {
348 | active_sessions: sessionsInfo.length,
349 | max_sessions: this.maxSessions,
350 | session_timeout: this.sessionTimeout,
351 | oldest_session_seconds: oldestSessionSeconds,
352 | total_messages: totalMessages,
353 | };
354 | }
355 | }
356 |
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * NotebookLM MCP Server
5 | *
6 | * MCP Server for Google NotebookLM - Chat with Gemini 2.5 through NotebookLM
7 | * with session support and human-like behavior!
8 | *
9 | * Features:
10 | * - Session-based contextual conversations
11 | * - Auto re-login on session expiry
12 | * - Human-like typing and mouse movements
13 | * - Persistent browser fingerprint
14 | * - Stealth mode with Patchright
15 | * - Claude Code integration via npx
16 | *
17 | * Usage:
18 | * npx notebooklm-mcp
19 | * node dist/index.js
20 | *
21 | * Environment Variables:
22 | * NOTEBOOK_URL - Default NotebookLM notebook URL
23 | * AUTO_LOGIN_ENABLED - Enable automatic login (true/false)
24 | * LOGIN_EMAIL - Google email for auto-login
25 | * LOGIN_PASSWORD - Google password for auto-login
26 | * HEADLESS - Run browser in headless mode (true/false)
27 | * MAX_SESSIONS - Maximum concurrent sessions (default: 10)
28 | * SESSION_TIMEOUT - Session timeout in seconds (default: 900)
29 | *
30 | * Based on the Python NotebookLM API implementation
31 | */
32 |
33 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
34 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
35 | import {
36 | CallToolRequestSchema,
37 | ListToolsRequestSchema,
38 | Tool,
39 | } from "@modelcontextprotocol/sdk/types.js";
40 |
41 | import { AuthManager } from "./auth/auth-manager.js";
42 | import { SessionManager } from "./session/session-manager.js";
43 | import { NotebookLibrary } from "./library/notebook-library.js";
44 | import { ToolHandlers, buildToolDefinitions } from "./tools/index.js";
45 | import { ResourceHandlers } from "./resources/resource-handlers.js";
46 | import { SettingsManager } from "./utils/settings-manager.js";
47 | import { CliHandler } from "./utils/cli-handler.js";
48 | import { CONFIG } from "./config.js";
49 | import { log } from "./utils/logger.js";
50 |
51 | /**
52 | * Main MCP Server Class
53 | */
54 | class NotebookLMMCPServer {
55 | private server: Server;
56 | private authManager: AuthManager;
57 | private sessionManager: SessionManager;
58 | private library: NotebookLibrary;
59 | private toolHandlers: ToolHandlers;
60 | private resourceHandlers: ResourceHandlers;
61 | private settingsManager: SettingsManager;
62 | private toolDefinitions: Tool[];
63 |
64 | constructor() {
65 | // Initialize MCP Server
66 | this.server = new Server(
67 | {
68 | name: "notebooklm-mcp",
69 | version: "1.1.0",
70 | },
71 | {
72 | capabilities: {
73 | tools: {},
74 | resources: {},
75 | resourceTemplates: {},
76 | prompts: {}, // Required for completion/complete support in some clients
77 | logging: {},
78 | },
79 | }
80 | );
81 |
82 | // Initialize managers
83 | this.authManager = new AuthManager();
84 | this.sessionManager = new SessionManager(this.authManager);
85 | this.library = new NotebookLibrary();
86 | this.settingsManager = new SettingsManager();
87 |
88 | // Initialize handlers
89 | this.toolHandlers = new ToolHandlers(
90 | this.sessionManager,
91 | this.authManager,
92 | this.library
93 | );
94 | this.resourceHandlers = new ResourceHandlers(this.library);
95 |
96 | // Build and Filter tool definitions
97 | const allTools = buildToolDefinitions(this.library) as Tool[];
98 | this.toolDefinitions = this.settingsManager.filterTools(allTools);
99 |
100 | // Setup handlers
101 | this.setupHandlers();
102 | this.setupShutdownHandlers();
103 |
104 | const activeSettings = this.settingsManager.getEffectiveSettings();
105 | log.info("🚀 NotebookLM MCP Server initialized");
106 | log.info(` Version: 1.1.0`);
107 | log.info(` Node: ${process.version}`);
108 | log.info(` Platform: ${process.platform}`);
109 | log.info(` Profile: ${activeSettings.profile} (${this.toolDefinitions.length} tools active)`);
110 | }
111 |
112 | /**
113 | * Setup MCP request handlers
114 | */
115 | private setupHandlers(): void {
116 | // Register Resource Handlers (Resources, Templates, Completions)
117 | this.resourceHandlers.registerHandlers(this.server);
118 |
119 | // List available tools
120 | this.server.setRequestHandler(ListToolsRequestSchema, async () => {
121 | log.info("📋 [MCP] list_tools request received");
122 | return {
123 | tools: this.toolDefinitions,
124 | };
125 | });
126 |
127 | // Handle tool calls
128 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
129 | const { name, arguments: args } = request.params;
130 | const progressToken = (args as any)?._meta?.progressToken;
131 |
132 | log.info(`🔧 [MCP] Tool call: ${name}`);
133 | if (progressToken) {
134 | log.info(` 📊 Progress token: ${progressToken}`);
135 | }
136 |
137 | // Create progress callback function
138 | const sendProgress = async (message: string, progress?: number, total?: number) => {
139 | if (progressToken) {
140 | await this.server.notification({
141 | method: "notifications/progress",
142 | params: {
143 | progressToken,
144 | message,
145 | ...(progress !== undefined && { progress }),
146 | ...(total !== undefined && { total }),
147 | },
148 | });
149 | log.dim(` 📊 Progress: ${message}`);
150 | }
151 | };
152 |
153 | try {
154 | let result;
155 |
156 | switch (name) {
157 | case "ask_question":
158 | result = await this.toolHandlers.handleAskQuestion(
159 | args as {
160 | question: string;
161 | session_id?: string;
162 | notebook_id?: string;
163 | notebook_url?: string;
164 | show_browser?: boolean;
165 | },
166 | sendProgress
167 | );
168 | break;
169 |
170 | case "add_notebook":
171 | result = await this.toolHandlers.handleAddNotebook(
172 | args as {
173 | url: string;
174 | name: string;
175 | description: string;
176 | topics: string[];
177 | content_types?: string[];
178 | use_cases?: string[];
179 | tags?: string[];
180 | }
181 | );
182 | break;
183 |
184 | case "list_notebooks":
185 | result = await this.toolHandlers.handleListNotebooks();
186 | break;
187 |
188 | case "get_notebook":
189 | result = await this.toolHandlers.handleGetNotebook(
190 | args as { id: string }
191 | );
192 | break;
193 |
194 | case "select_notebook":
195 | result = await this.toolHandlers.handleSelectNotebook(
196 | args as { id: string }
197 | );
198 | break;
199 |
200 | case "update_notebook":
201 | result = await this.toolHandlers.handleUpdateNotebook(
202 | args as {
203 | id: string;
204 | name?: string;
205 | description?: string;
206 | topics?: string[];
207 | content_types?: string[];
208 | use_cases?: string[];
209 | tags?: string[];
210 | url?: string;
211 | }
212 | );
213 | break;
214 |
215 | case "remove_notebook":
216 | result = await this.toolHandlers.handleRemoveNotebook(
217 | args as { id: string }
218 | );
219 | break;
220 |
221 | case "search_notebooks":
222 | result = await this.toolHandlers.handleSearchNotebooks(
223 | args as { query: string }
224 | );
225 | break;
226 |
227 | case "get_library_stats":
228 | result = await this.toolHandlers.handleGetLibraryStats();
229 | break;
230 |
231 | case "list_sessions":
232 | result = await this.toolHandlers.handleListSessions();
233 | break;
234 |
235 | case "close_session":
236 | result = await this.toolHandlers.handleCloseSession(
237 | args as { session_id: string }
238 | );
239 | break;
240 |
241 | case "reset_session":
242 | result = await this.toolHandlers.handleResetSession(
243 | args as { session_id: string }
244 | );
245 | break;
246 |
247 | case "get_health":
248 | result = await this.toolHandlers.handleGetHealth();
249 | break;
250 |
251 | case "setup_auth":
252 | result = await this.toolHandlers.handleSetupAuth(
253 | args as { show_browser?: boolean },
254 | sendProgress
255 | );
256 | break;
257 |
258 | case "re_auth":
259 | result = await this.toolHandlers.handleReAuth(
260 | args as { show_browser?: boolean },
261 | sendProgress
262 | );
263 | break;
264 |
265 | case "cleanup_data":
266 | result = await this.toolHandlers.handleCleanupData(
267 | args as { confirm: boolean }
268 | );
269 | break;
270 |
271 | default:
272 | log.error(`❌ [MCP] Unknown tool: ${name}`);
273 | return {
274 | content: [
275 | {
276 | type: "text",
277 | text: JSON.stringify(
278 | {
279 | success: false,
280 | error: `Unknown tool: ${name}`,
281 | },
282 | null,
283 | 2
284 | ),
285 | },
286 | ],
287 | };
288 | }
289 |
290 | // Return result
291 | return {
292 | content: [
293 | {
294 | type: "text",
295 | text: JSON.stringify(result, null, 2),
296 | },
297 | ],
298 | };
299 | } catch (error) {
300 | const errorMessage =
301 | error instanceof Error ? error.message : String(error);
302 | log.error(`❌ [MCP] Tool execution error: ${errorMessage}`);
303 |
304 | return {
305 | content: [
306 | {
307 | type: "text",
308 | text: JSON.stringify(
309 | {
310 | success: false,
311 | error: errorMessage,
312 | },
313 | null,
314 | 2
315 | ),
316 | },
317 | ],
318 | };
319 | }
320 | });
321 | }
322 |
323 | /**
324 | * Setup graceful shutdown handlers
325 | */
326 | private setupShutdownHandlers(): void {
327 | let shuttingDown = false;
328 |
329 | const shutdown = async (signal: string) => {
330 | if (shuttingDown) {
331 | return;
332 | }
333 | shuttingDown = true;
334 |
335 | log.info(`\n🛑 Received ${signal}, shutting down gracefully...`);
336 |
337 | try {
338 | // Cleanup tool handlers (closes all sessions)
339 | await this.toolHandlers.cleanup();
340 |
341 | // Close server
342 | await this.server.close();
343 |
344 | log.success("✅ Shutdown complete");
345 | process.exit(0);
346 | } catch (error) {
347 | log.error(`❌ Error during shutdown: ${error}`);
348 | process.exit(1);
349 | }
350 | };
351 |
352 | const requestShutdown = (signal: string) => {
353 | void shutdown(signal);
354 | };
355 |
356 | process.on("SIGINT", () => requestShutdown("SIGINT"));
357 | process.on("SIGTERM", () => requestShutdown("SIGTERM"));
358 |
359 | process.on("uncaughtException", (error) => {
360 | log.error(`💥 Uncaught exception: ${error}`);
361 | log.error(error.stack || "");
362 | requestShutdown("uncaughtException");
363 | });
364 |
365 | process.on("unhandledRejection", (reason, promise) => {
366 | log.error(`💥 Unhandled rejection at: ${promise}`);
367 | log.error(`Reason: ${reason}`);
368 | requestShutdown("unhandledRejection");
369 | });
370 | }
371 |
372 | /**
373 | * Start the MCP server
374 | */
375 | async start(): Promise<void> {
376 | log.info("🎯 Starting NotebookLM MCP Server...");
377 | log.info("");
378 | log.info("📝 Configuration:");
379 | log.info(` Config Dir: ${CONFIG.configDir}`);
380 | log.info(` Data Dir: ${CONFIG.dataDir}`);
381 | log.info(` Headless: ${CONFIG.headless}`);
382 | log.info(` Max Sessions: ${CONFIG.maxSessions}`);
383 | log.info(` Session Timeout: ${CONFIG.sessionTimeout}s`);
384 | log.info(` Stealth: ${CONFIG.stealthEnabled}`);
385 | log.info("");
386 |
387 | // Create stdio transport
388 | const transport = new StdioServerTransport();
389 |
390 | // Connect server to transport
391 | await this.server.connect(transport);
392 |
393 | log.success("✅ MCP Server connected via stdio");
394 | log.success("🎉 Ready to receive requests from Claude Code!");
395 | log.info("");
396 | log.info("💡 Available tools:");
397 | for (const tool of this.toolDefinitions) {
398 | const desc = tool.description ? tool.description.split('\n')[0] : 'No description'; // First line only
399 | log.info(` - ${tool.name}: ${desc.substring(0, 80)}...`);
400 | }
401 | log.info("");
402 | log.info("📖 For documentation, see: README.md");
403 | log.info("📖 For MCP details, see: MCP_INFOS.md");
404 | log.info("");
405 | }
406 | }
407 |
408 | /**
409 | * Main entry point
410 | */
411 | async function main() {
412 | // Handle CLI commands
413 | const args = process.argv.slice(2);
414 | if (args.length > 0 && args[0] === "config") {
415 | const cli = new CliHandler();
416 | await cli.handleCommand(args);
417 | process.exit(0);
418 | }
419 |
420 | // Print banner
421 | console.error("╔══════════════════════════════════════════════════════════╗");
422 | console.error("║ ║");
423 | console.error("║ NotebookLM MCP Server v1.0.0 ║");
424 | console.error("║ ║");
425 | console.error("║ Chat with Gemini 2.5 through NotebookLM via MCP ║");
426 | console.error("║ ║");
427 | console.error("╚══════════════════════════════════════════════════════════╝");
428 | console.error("");
429 |
430 | try {
431 | const server = new NotebookLMMCPServer();
432 | await server.start();
433 | } catch (error) {
434 | log.error(`💥 Fatal error starting server: ${error}`);
435 | if (error instanceof Error) {
436 | log.error(error.stack || "");
437 | }
438 | process.exit(1);
439 | }
440 | }
441 |
442 | // Run the server
443 | main();
444 |
```
--------------------------------------------------------------------------------
/src/utils/page-utils.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Page utilities for extracting responses from NotebookLM web UI
3 | *
4 | * This module provides functions to:
5 | * - Extract latest assistant responses from the page
6 | * - Wait for new responses with streaming detection
7 | * - Detect placeholders and loading states
8 | * - Snapshot existing responses for comparison
9 | *
10 | * Based on the Python implementation from page_utils.py
11 | */
12 |
13 | import type { Page } from "patchright";
14 | import { log } from "./logger.js";
15 |
16 | // ============================================================================
17 | // Constants
18 | // ============================================================================
19 |
20 | /**
21 | * CSS selectors to find assistant response elements
22 | * Ordered by priority (most specific first)
23 | */
24 | const RESPONSE_SELECTORS = [
25 | ".to-user-container .message-text-content",
26 | "[data-message-author='bot']",
27 | "[data-message-author='assistant']",
28 | "[data-message-role='assistant']",
29 | "[data-author='assistant']",
30 | "[data-renderer*='assistant']",
31 | "[data-automation-id='response-text']",
32 | "[data-automation-id='assistant-response']",
33 | "[data-automation-id='chat-response']",
34 | "[data-testid*='assistant']",
35 | "[data-testid*='response']",
36 | "[aria-live='polite']",
37 | "[role='listitem'][data-message-author]",
38 | ];
39 |
40 |
41 | // ============================================================================
42 | // Helper Functions
43 | // ============================================================================
44 |
45 | /**
46 | * Simple string hash function (for efficient comparison)
47 | */
48 | function hashString(str: string): number {
49 | let hash = 0;
50 | for (let i = 0; i < str.length; i++) {
51 | const char = str.charCodeAt(i);
52 | hash = (hash << 5) - hash + char;
53 | hash = hash & hash; // Convert to 32bit integer
54 | }
55 | return hash;
56 | }
57 |
58 |
59 | // ============================================================================
60 | // Main Functions
61 | // ============================================================================
62 |
63 | /**
64 | * Snapshot the latest response text currently visible
65 | * Returns null if no response found
66 | */
67 | export async function snapshotLatestResponse(page: Page): Promise<string | null> {
68 | return await extractLatestText(page, new Set(), false, 0);
69 | }
70 |
71 | /**
72 | * Snapshot ALL existing assistant response texts
73 | * Used to capture visible responses BEFORE submitting a new question
74 | */
75 | export async function snapshotAllResponses(page: Page): Promise<string[]> {
76 | const allTexts: string[] = [];
77 | const primarySelector = ".to-user-container";
78 |
79 | try {
80 | const containers = await page.$$(primarySelector);
81 | if (containers.length > 0) {
82 | for (const container of containers) {
83 | try {
84 | const textElement = await container.$(".message-text-content");
85 | if (textElement) {
86 | const text = await textElement.innerText();
87 | if (text && text.trim()) {
88 | allTexts.push(text.trim());
89 | }
90 | }
91 | } catch {
92 | continue;
93 | }
94 | }
95 |
96 | log.info(`📸 [SNAPSHOT] Captured ${allTexts.length} existing responses`);
97 | }
98 | } catch (error) {
99 | log.warning(`⚠️ [SNAPSHOT] Failed to snapshot responses: ${error}`);
100 | }
101 |
102 | return allTexts;
103 | }
104 |
105 | /**
106 | * Count the number of visible assistant response elements
107 | */
108 | export async function countResponseElements(page: Page): Promise<number> {
109 | let count = 0;
110 | for (const selector of RESPONSE_SELECTORS) {
111 | try {
112 | const elements = await page.$$(selector);
113 | if (elements.length > 0) {
114 | // Count only visible elements
115 | for (const el of elements) {
116 | try {
117 | const isVisible = await el.isVisible();
118 | if (isVisible) {
119 | count++;
120 | }
121 | } catch {
122 | continue;
123 | }
124 | }
125 | // If we found elements with this selector, stop trying others
126 | if (count > 0) {
127 | break;
128 | }
129 | }
130 | } catch {
131 | continue;
132 | }
133 | }
134 | return count;
135 | }
136 |
137 | /**
138 | * Wait for a new assistant response with streaming detection
139 | *
140 | * This function:
141 | * 1. Polls the page for new response text
142 | * 2. Detects streaming (text changes) vs. complete (text stable)
143 | * 3. Requires text to be stable for 3 consecutive polls before returning
144 | * 4. Ignores placeholders, question echoes, and known responses
145 | *
146 | * @param page Playwright page instance
147 | * @param options Options for waiting
148 | * @returns The new response text, or null if timeout
149 | */
150 | export async function waitForLatestAnswer(
151 | page: Page,
152 | options: {
153 | question?: string;
154 | timeoutMs?: number;
155 | pollIntervalMs?: number;
156 | ignoreTexts?: string[];
157 | debug?: boolean;
158 | } = {}
159 | ): Promise<string | null> {
160 | const {
161 | question = "",
162 | timeoutMs = 120000,
163 | pollIntervalMs = 1000,
164 | ignoreTexts = [],
165 | debug = false,
166 | } = options;
167 |
168 | const deadline = Date.now() + timeoutMs;
169 | const sanitizedQuestion = question.trim().toLowerCase();
170 |
171 | // Track ALL known texts as HASHES (memory efficient!)
172 | const knownHashes = new Set<number>();
173 | for (const text of ignoreTexts) {
174 | if (typeof text === "string" && text.trim()) {
175 | knownHashes.add(hashString(text.trim()));
176 | }
177 | }
178 |
179 | if (debug) {
180 | log.debug(
181 | `🔍 [DEBUG] Waiting for NEW answer. Ignoring ${knownHashes.size} known responses`
182 | );
183 | }
184 |
185 | let pollCount = 0;
186 | let lastCandidate: string | null = null;
187 | let stableCount = 0; // Track how many times we see the same text
188 | const requiredStablePolls = 3; // Text must be stable for 3 consecutive polls
189 |
190 | while (Date.now() < deadline) {
191 | pollCount++;
192 |
193 | // Check if NotebookLM is still "thinking" (most reliable indicator)
194 | try {
195 | const thinkingElement = await page.$('div.thinking-message');
196 | if (thinkingElement) {
197 | const isVisible = await thinkingElement.isVisible();
198 | if (isVisible) {
199 | if (debug && pollCount % 5 === 0) {
200 | log.debug("🔍 [DEBUG] NotebookLM still thinking (div.thinking-message visible)...");
201 | }
202 | await page.waitForTimeout(pollIntervalMs);
203 | continue;
204 | }
205 | }
206 | } catch {
207 | // Ignore errors checking thinking state
208 | }
209 |
210 | // Extract latest NEW text
211 | const candidate = await extractLatestText(
212 | page,
213 | knownHashes,
214 | debug,
215 | pollCount
216 | );
217 |
218 | if (candidate) {
219 | const normalized = candidate.trim();
220 | if (normalized) {
221 | const lower = normalized.toLowerCase();
222 |
223 | // Check if it's the question echo
224 | if (lower === sanitizedQuestion) {
225 | if (debug) {
226 | log.debug("🔍 [DEBUG] Found question echo, ignoring");
227 | }
228 | knownHashes.add(hashString(normalized)); // Mark as seen
229 | await page.waitForTimeout(pollIntervalMs);
230 | continue;
231 | }
232 |
233 | // ========================================
234 | // STREAMING DETECTION: Check if text is stable
235 | // ========================================
236 | if (normalized === lastCandidate) {
237 | // Text hasn't changed - it's stable
238 | stableCount++;
239 | if (debug && stableCount === requiredStablePolls) {
240 | log.debug(
241 | `✅ [DEBUG] Text stable for ${stableCount} polls (${normalized.length} chars)`
242 | );
243 | }
244 | } else {
245 | // Text changed - streaming in progress
246 | if (debug && lastCandidate) {
247 | log.debug(
248 | `🔄 [DEBUG] Text changed (${normalized.length} chars, was ${lastCandidate.length})`
249 | );
250 | }
251 | stableCount = 1;
252 | lastCandidate = normalized;
253 | }
254 |
255 | // Only return once text is stable
256 | if (stableCount >= requiredStablePolls) {
257 | if (debug) {
258 | log.debug(`✅ [DEBUG] Returning stable answer (${normalized.length} chars)`);
259 | }
260 | return normalized;
261 | }
262 | }
263 | }
264 |
265 | await page.waitForTimeout(pollIntervalMs);
266 | }
267 |
268 | if (debug) {
269 | log.debug(`⏱️ [DEBUG] Timeout after ${pollCount} polls`);
270 | }
271 | return null;
272 | }
273 |
274 | /**
275 | * Extract the latest NEW response text from the page
276 | * Uses hash-based comparison for efficiency
277 | *
278 | * @param page Playwright page instance
279 | * @param knownHashes Set of hashes of already-seen response texts
280 | * @param debug Enable debug logging
281 | * @param pollCount Current poll number (for conditional logging)
282 | * @returns First NEW response text found, or null
283 | */
284 | async function extractLatestText(
285 | page: Page,
286 | knownHashes: Set<number>,
287 | debug: boolean,
288 | pollCount: number
289 | ): Promise<string | null> {
290 | // Try the primary selector first (most specific for NotebookLM)
291 | const primarySelector = ".to-user-container";
292 | try {
293 | const containers = await page.$$(primarySelector);
294 | const totalContainers = containers.length;
295 |
296 | // Early exit if no new containers possible
297 | if (totalContainers <= knownHashes.size) {
298 | if (debug && pollCount % 5 === 0) {
299 | log.dim(
300 | `⏭️ [EXTRACT] No new containers (${totalContainers} total, ${knownHashes.size} known)`
301 | );
302 | }
303 | return null;
304 | }
305 |
306 | if (containers.length > 0) {
307 | // Only log every 5th poll to reduce noise
308 | if (debug && pollCount % 5 === 0) {
309 | log.dim(
310 | `🔍 [EXTRACT] Scanning ${totalContainers} containers (${knownHashes.size} known)`
311 | );
312 | }
313 |
314 | let skipped = 0;
315 | let empty = 0;
316 |
317 | // Scan ALL containers to find the FIRST with NEW text
318 | for (let idx = 0; idx < containers.length; idx++) {
319 | const container = containers[idx];
320 | try {
321 | const textElement = await container.$(".message-text-content");
322 | if (textElement) {
323 | const text = await textElement.innerText();
324 | if (text && text.trim()) {
325 | // Hash-based comparison (faster & less memory)
326 | const textHash = hashString(text.trim());
327 | if (!knownHashes.has(textHash)) {
328 | log.success(
329 | `✅ [EXTRACT] Found NEW text in container[${idx}]: ${text.trim().length} chars`
330 | );
331 | return text.trim();
332 | } else {
333 | skipped++;
334 | }
335 | } else {
336 | empty++;
337 | }
338 | }
339 | } catch {
340 | continue;
341 | }
342 | }
343 |
344 | // Only log summary if debug enabled
345 | if (debug && pollCount % 5 === 0) {
346 | log.dim(
347 | `⏭️ [EXTRACT] No NEW text (skipped ${skipped} known, ${empty} empty)`
348 | );
349 | }
350 | return null; // Don't fall through to fallback!
351 | } else {
352 | if (debug) {
353 | log.warning("⚠️ [EXTRACT] No containers found");
354 | }
355 | }
356 | } catch (error) {
357 | log.error(`❌ [EXTRACT] Primary selector failed: ${error}`);
358 | }
359 |
360 | // Fallback: Try other selectors (only if primary selector failed/found nothing)
361 | if (debug) {
362 | log.dim("🔄 [EXTRACT] Trying fallback selectors...");
363 | }
364 |
365 | for (const selector of RESPONSE_SELECTORS) {
366 | try {
367 | const elements = await page.$$(selector);
368 | if (elements.length === 0) continue;
369 |
370 | // Scan ALL elements to find the first with NEW text
371 | for (const element of elements) {
372 | try {
373 | // Prefer full container text when available
374 | let container = element;
375 | try {
376 | const closest = await element.evaluateHandle((el) => {
377 | return el.closest(
378 | "[data-message-author], [data-message-role], [data-author], " +
379 | "[data-testid*='assistant'], [data-automation-id*='response'], article, section"
380 | );
381 | });
382 | if (closest) {
383 | container = closest.asElement() || element;
384 | }
385 | } catch {
386 | container = element;
387 | }
388 |
389 | const text = await container.innerText();
390 | if (text && text.trim() && !knownHashes.has(hashString(text.trim()))) {
391 | return text.trim();
392 | }
393 | } catch {
394 | continue;
395 | }
396 | }
397 | } catch {
398 | continue;
399 | }
400 | }
401 |
402 | // Final fallback: JavaScript evaluation
403 | try {
404 | const fallbackText = await page.evaluate((): string | null => {
405 | // @ts-expect-error - DOM types available in browser context
406 | const unique = new Set<Element>();
407 | // @ts-expect-error - DOM types available in browser context
408 | const isVisible = (el: Element): boolean => {
409 | // @ts-expect-error - DOM types available in browser context
410 | if (!el || !(el as HTMLElement).isConnected) return false;
411 | const rect = el.getBoundingClientRect();
412 | if (rect.width === 0 || rect.height === 0) return false;
413 | // @ts-expect-error - window available in browser context
414 | const style = window.getComputedStyle(el as HTMLElement);
415 | if (
416 | style.visibility === "hidden" ||
417 | style.display === "none" ||
418 | parseFloat(style.opacity || "1") === 0
419 | ) {
420 | return false;
421 | }
422 | return true;
423 | };
424 |
425 | const selectors = [
426 | "[data-message-author]",
427 | "[data-message-role]",
428 | "[data-author]",
429 | "[data-renderer*='assistant']",
430 | "[data-testid*='assistant']",
431 | "[data-automation-id*='response']",
432 | ];
433 |
434 | const candidates: string[] = [];
435 | for (const selector of selectors) {
436 | // @ts-expect-error - document available in browser context
437 | for (const el of document.querySelectorAll(selector)) {
438 | if (!isVisible(el)) continue;
439 | if (unique.has(el)) continue;
440 | unique.add(el);
441 |
442 | // @ts-expect-error - DOM types available in browser context
443 | const text = (el as HTMLElement).innerText || (el as HTMLElement).textContent || "";
444 | if (!text.trim()) continue;
445 |
446 | candidates.push(text.trim());
447 | }
448 | }
449 |
450 | if (candidates.length > 0) {
451 | return candidates[candidates.length - 1];
452 | }
453 |
454 | return null;
455 | });
456 |
457 | if (typeof fallbackText === "string" && fallbackText.trim()) {
458 | return fallbackText.trim();
459 | }
460 | } catch {
461 | // Ignore evaluation errors
462 | }
463 |
464 | return null;
465 | }
466 |
467 | // ============================================================================
468 | // Exports
469 | // ============================================================================
470 |
471 | export default {
472 | snapshotLatestResponse,
473 | snapshotAllResponses,
474 | countResponseElements,
475 | waitForLatestAnswer,
476 | };
477 |
```