Files
teng-lin--notebooklm-py/docs/mcp-guide.md
T
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

458 lines
32 KiB
Markdown

# MCP server guide
> **Experimental / preview.** The MCP server ships behind the optional `mcp` extra. Its
> tool surface (names, parameters, output shapes) is **not** covered by the library's semver
> guarantees and may change between releases. `pip install notebooklm-py` is unaffected — the
> server and its dependencies only arrive with the `mcp` extra.
The MCP server exposes NotebookLM to any [Model Context Protocol](https://modelcontextprotocol.io)
client (Claude Desktop, Claude Code, Cursor, Windsurf, …) as a set of **32 tools** — manage
notebooks and sources, chat over a notebook's sources, generate and download studio artifacts,
and run deep research. It is a thin adapter over the same business logic the CLI uses, so it
behaves identically to `notebooklm <command>`.
## Install
The server is behind the `mcp` extra (pulls in `fastmcp`):
```bash
pip install "notebooklm-py[mcp]"
# or run with no install, straight from PyPI:
uvx --from "notebooklm-py[mcp]" notebooklm-mcp --help
```
## Authenticate (once)
The server reuses the CLI's stored credentials — it does **not** log in on its own. Authenticate
once before starting it:
```bash
notebooklm login
# or, if you didn't install the package:
uvx --from "notebooklm-py[mcp]" notebooklm login
```
Credentials are stored per profile under `~/.notebooklm/`. The server binds the **active profile**
at startup (override with `--profile`, below). See [configuration.md](configuration.md) for profiles
and multi-account setup.
## Connect a client
The fastest path is the auto-config command, which writes the server block into a client's MCP
config (idempotent, never clobbers other servers):
```bash
notebooklm mcp install claude-desktop # or: claude-code | cursor | windsurf
```
| Client | Config written |
|--------|----------------|
| `claude-desktop` | `claude_desktop_config.json` (per-OS location) |
| `claude-code` | `~/.claude.json` (user scope) |
| `cursor` | `~/.cursor/mcp.json` |
| `windsurf` | `~/.codeium/windsurf/mcp_config.json` |
It writes a block that launches the server via `uvx` (so only `uv` needs to be on the host):
```jsonc
{
"mcpServers": {
"notebooklm": {
"command": "uvx",
"args": ["--from", "notebooklm-py[mcp]", "notebooklm-mcp"]
}
}
}
```
Restart the client after installing. For a one-click Claude Desktop bundle,
download `notebooklm-mcp.mcpb` from the
[latest release](https://github.com/teng-lin/notebooklm-py/releases/latest)
(**Assets**) and use "Install Extension"; see
[`desktop-extension/README.md`](../desktop-extension/README.md) for details.
## Run it directly
The console script is `notebooklm-mcp`:
```bash
notebooklm-mcp # stdio transport (default — for desktop hosts)
notebooklm-mcp --profile work # bind a specific auth profile
notebooklm-mcp --transport http # loopback streamable-HTTP on 127.0.0.1:9420
notebooklm-mcp --transport http --port 9000
```
| Flag | Default | Notes |
|------|---------|-------|
| `--profile` | active profile | which stored auth profile the process binds |
| `--transport` | `stdio` | `stdio` (subprocess hosts) or `http` (loopback) |
| `--host` | `127.0.0.1` | http only; non-loopback is **refused** unless `NOTEBOOKLM_MCP_ALLOW_EXTERNAL_BIND=1` |
| `--port` | `9420` | http only |
| `--log-level` | `INFO` | logs go to **stderr**; stdout stays pure JSON-RPC |
There is no `--token` flag — the HTTP bearer token is **env-only**
(`NOTEBOOKLM_MCP_TOKEN`) so it cannot leak via `ps aux`.
`stdio` is right for Claude Desktop/Code, Cursor, and Windsurf (they launch the server as a
subprocess). Use `http` for a local web client or to share one running server across clients on
the same machine. The HTTP transport is loopback-only by default; binding to a non-loopback
address requires **both** the explicit `NOTEBOOKLM_MCP_ALLOW_EXTERNAL_BIND=1` override **and** a
`NOTEBOOKLM_MCP_TOKEN` — the server fails closed (refuses to start) on a network bind without a
token, since it fronts a full Google account.
## Remote deployment (Docker + a tunnel)
Because master-token auth keeps the session alive unattended (no browser), the HTTP transport can
run as a **remote connector** reachable from Claude Code, Claude Desktop, claude.ai, mobile, and ChatGPT.
The [`deploy/`](../deploy/) directory ships a turn-key Docker + Compose stack with a **tunnel
sidecar** — pick one via a Compose profile — so you get HTTPS with **no public IP, no open ports,
and no TLS certificate to manage** (the tunnel terminates TLS at its edge).
**→ The full step-by-step lives in [`deploy/README.md`](../deploy/README.md)** — run it from
inside `deploy/` (`make setup` → finish the one manual tunnel step → `make up`), where the Compose
stack, `Makefile`, and `.env.example` it references sit. It walks both tunnels end to end:
**Cloudflare** (needs a domain in your Cloudflare account) and **Tailscale Funnel** (no domain — a
free, stable `*.ts.net` HTTPS hostname). The rest of this section is the two things worth knowing
before you start: the auth model and remote file transfer.
**Two auth methods coexist on one `/mcp`** (FastMCP `MultiAuth`):
- **Claude Code / Desktop** → the static `NOTEBOOKLM_MCP_TOKEN` bearer (an `Authorization` header).
- **claude.ai (web/mobile) and ChatGPT** (Developer Mode) → optional **self-hosted OAuth** (one
password, no external IdP): set `NOTEBOOKLM_MCP_OAUTH_PASSWORD` (≥16 random chars) +
`NOTEBOOKLM_MCP_OAUTH_BASE_URL` (the **bare public origin**, no `/mcp`). Both connector UIs are
OAuth-only (no bearer field). Unset → bearer-only (Claude Code/Desktop still work).
Use a **dedicated/throwaway Google account** — the mounted `master_token.json` is a durable
full-account credential. Multi-tenant hosting is out of scope for this single-tenant setup.
### File upload & download (remote)
The MCP/JSON-RPC channel can't carry large binaries, so over a remote connector
`source_add type=file` and `studio_download` broker a **short-lived signed URL**
served by the same container; your browser does the byte transfer (see
[ADR-0024](adr/0024-mcp-remote-file-transfer.md)). This is the standard pattern for
remote MCP file transfer — MCP has no native file-upload primitive, and its native
download (binary Resources) is capped far below a podcast/video. (A **small** file
can skip the signed URL entirely — see `source_upload_bytes` below.)
**Enable it:** set `NOTEBOOKLM_MCP_PUBLIC_URL` to your bare public origin (the same
host as the tunnel, no `/mcp`). It falls back to `NOTEBOOKLM_MCP_OAUTH_BASE_URL`, so
if you configured claude.ai OAuth above, **file transfer is already on**. Unset on a
bearer-only deploy → the two file tools return a clear "not configured" error
(everything else still works; the server does not refuse to start).
- **Upload a local file:** `source_add type=file` returns an `upload_required` link.
Open it in your browser, pick the file, and it's added to the notebook. (Claude can
also `PUT` a file it already holds to that link from its **code-execution sandbox**
but that requires Code Execution enabled **and your server domain whitelisted** in
claude.ai Settings → Capabilities → additional allowed domains, or the `PUT` fails.)
- **Hand a small file's bytes in-channel (no signed URL):** when an agent holds the
bytes but can complete *neither* upload path — e.g. its egress is blocked, so the
`agent_upload` POST fails, and no human device has the file — `source_upload_bytes`
takes the file as base64 (≤ 10,000 chars, ≈ 7 KB) and the connector adds it
server-side, returning the source directly. It works on any transport and needs no
`NOTEBOOKLM_MCP_PUBLIC_URL`; a larger file must use the `source_add type=file`
signed-URL flow above.
- **Download an artifact:** `studio_download` returns a `download_ready` link (a
clickable `resource_link`); open it to stream the podcast/video/PDF to your device.
The `download_ready` payload is self-describing so a client can render a download
affordance *before* opening the URL: alongside `url` / `expires_at` / `artifact_type`
/ `artifact_id` it carries `filename` (the artifact title — or the type name on a
latest-by-type download — plus the format-resolved extension), `mime_type` (from a
central per-type/format table the `/files/dl` route serves with, so the advertised
type and the streamed `Content-Type` can't drift), and `size_bytes` (`null` — the
size isn't known without eagerly fetching the artifact, which the broker won't do;
the route sets the real `Content-Length` when the link is opened).
- Links are HMAC-signed and short-lived (upload 15 min, download 30 min) and expire on
a server restart. Google Drive (`source_add` with a Drive id) remains a no-browser
alternative for adding files. stdio (local) installs are unchanged — they still read
and write real local paths directly.
## Core concepts
These conventions hold across every tool:
- **JSON by default.** Read/wait tools, including `source_read`, `source_wait`, and
`source_add_and_wait`, return a JSON text content block plus the same
`structured_content`. A `resource_link` appears only when a tool explicitly brokers
file transfer, such as `studio_download`.
- **Name *or* ID.** Every `notebook`/`source`/`note`/`artifact` argument accepts a human title **or**
an ID. Both resolve by prefix: an exact title wins, otherwise a **unique title prefix** matches
(so `"Scientific"` finds `"Scientific PDF Parsing — …"`), and likewise a full ID or a unique ID
prefix. Use the matching `*_list` tool to discover them. An ambiguous name or prefix returns a
`VALIDATION` error listing the candidates so you can retry with an exact title or ID. When a name
lookup *fails* but is close to a real title — a punctuation-only slip such as a hyphen typed for an
em-dash (`—`) or a normal space for a non-breaking one — the error's `Did you mean: …` hint names
up to three near-miss candidates, each with its **title and id** inline, so you can retry with the
full title or id instead of guessing (a label near-miss reached via `source_list(label=…)` gets the
same enrichment on its `VALIDATION` error).
- **Canonical IDs come back.** Every response echoes the canonical `notebook_id` (and, where a
tool resolves them, the `source_ids` scope / `artifact_id`) — so a call made by *name* hands you
the id to chain the next call on.
- **Strict IDs-only mode (opt-in).** Set `NOTEBOOKLM_MCP_STRICT_IDS=1` on the server to require a
**full canonical id** for every `notebook`/`source`/`note`/`artifact` reference: names, titles, and
short id prefixes are rejected with a `VALIDATION` error *before* any list call. This trades the
convenience above for deterministic, fail-fast behavior in long-lived automation, where a prefix or
title that is unique today can quietly resolve to a different (or ambiguous) entity tomorrow. Off by
default. (Governs every notebook/source/note/artifact reference — including studio `item` and
`studio_download`'s `artifact_id`; the `source_list(label=…)` name filter is out of scope.)
- **Destructive tools need confirmation.** `notebook_delete`, `source_delete`,
`studio_delete`, and `share_remove_user` take `confirm` (default `false`). Called without it, they return a `needs_confirmation` preview
(with the resolved title) and delete **nothing**; call again with `confirm=true` to execute.
- **Sharing-widening tools need confirmation too.** `share_set_user` (every grant/regrade) and
`share_set_access` when it would widen link access (`public=true` on a currently-restricted
notebook) take `confirm` (default `false`) and return a `needs_confirmation` preview instead of
mutating; call again with `confirm=true` to apply. Restricting (`public=false`) and
`view_level`-only changes are not gated. These tools are *not* flagged `destructiveHint` — the
gate is on the widening direction only.
- **Long-running work is non-blocking.** `studio_generate` returns immediately with a `task_id`;
poll `studio_status` until it's complete, then `studio_download`. Research is the same shape:
`research_start``research_status``research_import`.
- **Mutation envelope.** Synchronous create/rename/update/delete tools return a top-level
`status` string naming the outcome — one of `created`, `renamed`, `updated`, `deleted`,
`removed`, `added`, `imported`, `cancel_requested`, `configured` (plus the `needs_confirmation` /
`upload_required` / `download_ready` flow values) — alongside the affected id(s). An agent can
branch on `status` uniformly instead of learning a different success shape per tool. Two
carve-outs: the **long-running starters** `studio_generate` / `research_start` return a
`task_id` (the handle *is* the result — poll it) rather than a mutation status — as does the
re-runner `studio_retry` (its `task_id` equals the artifact id); and the **read** tools
`studio_status` / `research_status` key `status` to a lifecycle vocabulary
(`in_progress` / `completed` / …), a *different* enum. (Batch
`source_add` reports `added` once ≥1 succeeded, `error` if all failed — see the `added` /
`failed` tallies + per-item `results[].status` for partial outcomes.)
- **Structured errors.** Failures arrive as `CODE: message (retriable=…)`, where `CODE` is one of
`AUTH`, `RATE_LIMITED`, `NOT_FOUND`, `VALIDATION`, `TIMEOUT`, `NETWORK`, `SERVER`, `RPC`,
`CONFIG`, `NOTEBOOK_LIMIT`, `ARTIFACT_TIMEOUT`, `SOURCE_MUTATION`, `ERROR`, or `UNEXPECTED`. The
`retriable` flag tells an agent whether a retry could succeed (e.g. `RATE_LIMITED`, `TIMEOUT`,
`NETWORK`). Many errors also carry an actionable `hint` (e.g. `AUTH → run notebooklm login`); a
near-miss name lookup puts its `Did you mean: …` candidates (title + id) in that hint (see
**Name *or* ID** above).
## Workflows
The examples below are MCP **tool calls** an agent makes (not shell commands).
### Add sources and ask a question
```text
nb = notebook_create(title="Quantum Computing")
source_add(notebook="Quantum Computing", source_type="url", url="https://arxiv.org/abs/...")
source_add(notebook="Quantum Computing", source_type="text", title="Notes", text="...")
source_wait(notebook="Quantum Computing") # block until sources finish processing
chat_ask(notebook="Quantum Computing", question="What are the open problems?")
```
`source_wait` returns a structured aggregate: the four buckets (`ready` carries
`source_view` rows; `timed_out`/`failed`/`not_found` carry `{source_id, error}`)
plus explicit `*_count` scalars and a `total_count` for at-a-glance triage — so a
client reads the counts without folding `len()` over every array. The counts are
additive; the arrays stay for backward compatibility. `ok` is `true` iff every
error bucket is empty, and `total_count` = `ready_count` + `timed_out_count` +
`failed_count` + `not_found_count`:
```text
source_wait(notebook="Quantum Computing")
# → {"notebook_id": ..., "ok": false,
# "ready": [{"id": ..., "title": "Notes", "kind": "pasted_text", "status_label": "ready"}],
# "timed_out": [{"source_id": ..., "error": "..."}],
# "failed": [],
# "not_found": [],
# "ready_count": 1, "timed_out_count": 1, "failed_count": 0,
# "not_found_count": 0, "total_count": 2}
```
`source_type` is one of `url`, `text`, `file` (local `path`), `drive` (a
`document_id` + a **required** `mime_type`, one of
`google-doc`/`google-slides`/`google-sheets`/`pdf` — there is no default, since
defaulting a non-Doc Drive file to `google-doc` fails the import), or `youtube`.
URL and YouTube adds reject
internal/loopback hosts by default; pass `allow_internal=true` only for
deliberate local NotebookLM tests. `chat_ask` continues the most-recent
conversation unless you pass a `conversation_id`.
To add ONE source and block until it finishes processing in a single call, use
`source_add_and_wait` — it composes single-mode `source_add` + `source_wait`, so
you skip the add→wait round-trip. It takes the same single-mode add inputs plus
the `timeout`/`interval` wait knobs, and returns the `source_wait` aggregate plus
a top-level `source_id` (always present — the source persists even if the wait
times out or the import fails, so you can retry or delete it):
```text
source_add_and_wait(notebook="Quantum Computing", source_type="url",
url="https://arxiv.org/abs/...")
# → {"notebook_id": ..., "ok": true,
# "ready": [{"id": ..., "title": "...", "kind": "web_page", "status_label": "ready"}],
# "timed_out": [], "failed": [], "not_found": [],
# "ready_count": 1, "timed_out_count": 0, "failed_count": 0,
# "not_found_count": 0, "total_count": 1, "source_id": ...}
```
It is single-source only (no `urls` batch) and cannot one-shot a **remote** `file`
upload (that upload is a separate step — use `source_add(source_type="file")` then
`source_wait`, or `source_upload_bytes` for a tiny file).
To ingest many URLs at once, pass `urls` (batch mode) instead of `source_type`
— one call instead of one round-trip each. The response is an explicit per-item
list so a partial failure is never hidden behind a single success flag:
```text
source_add(notebook="Quantum Computing", urls=[
"https://arxiv.org/abs/2401.00001",
"https://www.youtube.com/watch?v=...",
])
# → {"notebook_id": ..., "added": 2, "failed": 0,
# "results": [{"input": "https://arxiv.org/abs/2401.00001", "status": "added", "source_id": ..., "title": ...},
# {"input": "https://www.youtube.com/watch?v=...", "status": "added", "source_id": ..., "title": ...}]}
```
Batch mode is URL-only (a non-URL entry is reported as a per-item `VALIDATION`
error, never added as text); `source_type`/`url`/`text`/`title`/`path`/
`document_id`/`mime_type` are not valid with `urls`, but `allow_internal`
applies to every entry.
### Content-sanity warnings on ready web pages
A dead link, [soft-404](https://en.wikipedia.org/wiki/HTTP_404#Soft_404), or
paywalled page frequently ingests as a **READY** source with little-to-no
extractable text — a "ghost source" that add-time status can't catch because a
soft-404 serves HTTP 200. `source_wait` — and batch `source_add(urls=[...])` for
an item that is *already* READY the moment it returns (single-mode `source_add`
adds asynchronously, so it never runs this check) — attaches a non-blocking,
advisory `warning` to such a source. The check is **best-effort and never
rejects**: the source stays READY, `ok` stays `true`, and any fetch failure
(including a >5s slow `source_read`) degrades to no warning rather than breaking
the wait.
It fires on a **web-page source only** (`kind == "web_page"`) via two body-only
signals — the title is never scanned:
| Signal | Threshold | Warning contains |
|--------|-----------|------------------|
| **char-thin** | indexed text shorter than **100 characters** | `"little/no text extracted (N chars) …"` |
| **dead-link boilerplate** | **indexed text** shorter than **2000 characters** that (casefolded) contains any of the phrases below | `"ingested as ready (N chars) but the body matches a dead-link / error-page pattern …"` |
The full dead-link phrase set (the complete list, so you can build a fixture that
trips it): `broken link`, `page not found`, `page isn't available`, `page does
not exist`, `page no longer available`, `no longer available`, `error 404`, `404
not found`, `whoops!`.
Both gates measure the source's **indexed text** length (`char_count` from a
`source_read` with `detail="full"`), not the raw HTTP response — a large HTML
page that indexes to little text is still caught. The 2000-char gate is what
keeps the weaker phrases safe: a page whose indexed text is 2000 chars or longer
is never phrase-scanned (so `broken link` in a real article about broken links,
or a shop's `no longer available`, does not false-positive), and the phrases are
all multi-word / anchored — no bare `404` or `not found`. Every warning ends with
`verify with source_read (detail="full").` (trailing period included).
**To exercise the warning branch** (the reason this is documented): note that a
`text` source — even an empty one — is *never* flagged; only a `web_page` under
the thresholds above is. So the reliable trigger is a URL that resolves to a
near-empty or soft-404 page. To unit-test your own handling of the branch
without a live URL, mock the source's fetched body under the threshold and assert
the warning shape — copy the pattern from
[`tests/unit/mcp/test_sources.py`](../tests/unit/mcp/test_sources.py) (see
`test_source_wait_thin_web_page_warns`, `test_source_wait_soft_404_body_phrase_warns`,
and the `_THIN_SOURCE_CHAR_THRESHOLD` boundary test).
### Generate and download a studio artifact
```text
task = studio_generate(notebook="Quantum Computing", artifact_type="audio")
studio_status(notebook="Quantum Computing", task_id="<task_id from above>") # poll until complete
studio_download(notebook="Quantum Computing", artifact_type="audio", path="podcast.mp3")
# Target a specific/older artifact instead of the latest-by-type (full ID or unique prefix):
studio_download(notebook="Quantum Computing", artifact_type="audio", path="old_podcast.mp3", artifact_id="aaaaaaaa-aaaa")
# Per-kind styling options are agent-settable, e.g. a custom-styled video:
studio_generate(notebook="Quantum Computing", artifact_type="video",
style="custom", style_prompt="hand-drawn diagrams")
```
`studio_download`'s `output_format` overrides the download file format, but only
some artifact types have a format axis:
| `artifact_type` | Supported `output_format` |
| --- | --- |
| `slide-deck` | `pdf` (default), `pptx` |
| `quiz`, `flashcards` | `json` (default), `markdown`, `html` |
| `audio`, `video`, `infographic`, `report`, `mind-map`, `data-table` | none — omit `output_format` |
Passing `output_format` to a type with no format axis (e.g. `report` +
`markdown`) is a validation error that says `supported formats: default only`
rather than silently ignoring it, and the message is identical whether the
download runs over stdio (`path`) or the remote signed-URL connector.
`artifact_type` is one of `audio`, `video`, `cinematic-video`, `slide-deck`, `quiz`, `flashcards`,
`infographic`, `data-table`, `mind-map`, `report`. Each kind's styling options are agent-settable
(matching the CLI flags): `audio_format` / `audio_length` (audio); `video_format` / `style` /
`style_prompt` (video — `style` / `style_prompt` are rejected for `video_format` `cinematic` and
`short`, which use a fixed visual style); `deck_format` / `deck_length` (slide-deck); `quantity` / `difficulty`
(quiz, flashcards); `orientation` / `detail` / `style` (infographic); `map_kind` (mind-map);
and `report_format` (report). `cinematic-video` and `data-table` take no per-kind options. An
option is valid only for its own kind — passing one to a different `artifact_type` is a
validation error, not a silent no-op.
### Run deep research and import the findings
```text
task = research_start(notebook="Quantum Computing", query="post-quantum cryptography", source="web", mode="deep")
research_status(notebook="Quantum Computing", poll_task_id=task["poll_task_id"])
research_import(notebook="Quantum Computing", poll_task_id=task["poll_task_id"])
```
`source` is `web` or `drive`; `mode` is `fast` or `deep`. Pass the
`poll_task_id` returned by `research_start` — under the **same** parameter name,
`poll_task_id` — when polling, importing, or cancelling, so the value copies
verbatim from one tool's output into the next and the request is pinned to the
intended research task (for a **deep** run it is the `report_id`; the raw
`task_id` is an unpollable sessionId). Omitting the pin on `research_status` is
allowed only when the notebook has a single in-flight task. `research_status`
omits the large report by default — pass `include_report=true` to fetch it once
`completed`.
> **Deprecated (removed in v0.9.0):** `research_status`/`research_import` also
> accept the old `task_id` name and `research_cancel` the old `run_id` name as
> aliases for `poll_task_id`. Passing an alias still works but emits a
> `DeprecationWarning` and adds a `deprecation` note to the result — switch to
> `poll_task_id`. See [docs/deprecations.md](deprecations.md).
## Tool reference
| Domain | Tools |
|--------|-------|
| **Notebooks** | `notebook_list(limit?, offset?)` · `notebook_create(title)` · `notebook_describe(notebook, include_metadata?)` (AI description; `include_metadata=true` adds a `metadata` block with notebook details + source list) · `notebook_rename(notebook, new_title)` · `notebook_delete(notebook, confirm)` |
| **Sources** | `source_list(notebook, status?, label?, detail?, limit?, offset?)` (each source has string `kind`/`status_label`; `status` filters to one of ready\|processing\|error\|preparing — e.g. `status="error"` finds failed imports; `detail=compact` returns a low-token roster of just `id`/`title`/`kind`/`status_label`/`created_at`) · `source_read(notebook, source, detail?, output_format?, max_chars?, offset?)` (`detail=full` (default) → metadata + a bounded slice of the indexed text: `max_chars` caps `content` (default 10k), `offset` pages, plus a `truncated` flag and the full `char_count`; `detail=summary` → low-token triage: AI summary **+ keywords**, not the body; `output_format`: text\|markdown) · `source_rename(notebook, source, new_title)` · `source_delete(notebook, source, confirm)` · `source_wait(notebook, source?, timeout, interval)` (a READY web page with thin/empty text, or a short body matching a dead-link / soft-404 boilerplate pattern, carries a non-blocking `warning`) · `source_add(notebook, source_type, ..., allow_internal?)` (single; echoes `kind`/`status_label`, flags a failed import inline with a `warning`) / `source_add(notebook, urls=[...], allow_internal?)` (batch → per-item `results`; a synchronously-ready web-page item may also carry the same content-sanity `warning`) · `source_add_and_wait(notebook, source_type, ..., timeout?, interval?)` (single-mode `source_add` + `source_wait` in one call → the `source_wait` aggregate plus a top-level `source_id`; not for batch or a remote `file` upload) |
| **Chat** | `chat_ask(notebook, question?, conversation_id?, references?, source_ids?, history?, suggest_followups?)` (`references`: lite\|full; never returns the raw debug blob; `source_ids` scopes to specific sources — list, JSON-array string, or comma string; omit for all; `history`>0 also returns up to N prior `{question, answer}` pairs — omit `question` to recall only; `suggest_followups=true` also returns `suggested_prompts` (3 questions to ask — works question-less too)) · `chat_configure(notebook, chat_mode?, goal?, response_length?)` (`chat_mode`: default\|learning-guide\|concise\|detailed — a preset, mutually exclusive with `goal`/`response_length`; a **partial** custom call sets just `goal` or just `response_length` and **merges** with the current settings — the omitted field is preserved, not reset; only a bare call, no preset and neither field, is rejected) · `suggest_prompts(notebook, surface?, source_ids?, query?)` (READ_ONLY; `surface`: ask\|audio-deep-dive\|audio-brief\|audio-critique\|audio-debate\|video-explainer\|video-short\|quiz\|flashcards — returns `{title, prompt}` suggestions to steer that studio surface; `ask` (default) = chat questions) |
| **Notes** | `note_save(notebook, note?, title?, content?)` (upsert: omit `note` to **create**`title` AND `content` required; pass a `note` ref to **update**`title` and/or `content`, title-only = rename). Reading and deleting notes fold into the Studio row below. |
| **Studio** | `studio_list(notebook, item?, kind?, detail?, limit?, offset?)` (the unified Studio panel — **notes AND artifacts** merged into one `items` list; each item has `id`/`title`/`type` where `type` is `note` or a hyphenated artifact kind; artifacts add `status_label`/`url`; `detail=summary` (default) gives each note a bounded `content_preview` + full-body `char_count` to keep a discovery listing low-token, `detail=full` returns the whole note `content`, `detail=compact` collapses every item to `id`/`title`/`type`/`status_label`/`created_at`; `kind` filters to one `type`; `item` fetches one note-or-artifact by ref as a 1-element list, always with the note's full `content`) · `studio_generate(notebook, artifact_type, …)` · `studio_status(notebook, task_id)` · `studio_get_prompt(notebook, artifact)` (the free-text prompt an artifact was generated from; `null` if none) · `studio_download(notebook, artifact? \| artifact_type?, path?, output_format?, artifact_id?)` (target by `artifact` name-or-id ref **or** by `artifact_type` [+ `artifact_id` for a specific one, else latest]) · `studio_rename(notebook, item, new_title)` (cross-type: renames a note OR an artifact resolved from the merged list) · `studio_retry(notebook, artifact)` (re-run a failed artifact in place; task_id == artifact_id) · `studio_delete(notebook, item, confirm)` (cross-type: deletes a note OR an artifact resolved from the merged list) |
| **Research** | `research_start(notebook, query, source, mode)` (returns `poll_task_id` — the one id status/import/cancel drive off) · `research_status(notebook, poll_task_id?, include_report?, report_max_chars?, source_limit?, source_offset?)` (report + per-source `report_markdown` omitted unless `include_report`) · `research_import(notebook, poll_task_id)` · `research_cancel(notebook, poll_task_id)` (sends the cancel unless the run is already terminal → `cancel_requested`). The old `task_id` / `run_id` param names are deprecated aliases for `poll_task_id`, removed in v0.9.0 |
| **Sharing** | `share_status(notebook)` (is_public/access/share_url/shared_users; enums as string labels; `view_level` omitted — the read API can't report it) · `share_set_access(notebook, public?, view_level?, confirm)` (link settings; `view_level`: full\|chat, echoed back only when set; `confirm` gates public widening restricted→public) · `share_set_user(notebook, email, permission?, notify?, message?, confirm)` (upsert grant; `permission`: editor\|viewer; `notify` defaults `false`; `confirm` gates every grant) · `share_remove_user(notebook, email, confirm)` |
| **Server** | `server_info(include_account?)` — version + local auth health; `include_account=true` adds an `account` block: signed-in identity (`email`, `authuser`) plus notebook/source limits, the subscription `tier` (opaque enum, e.g. `1`=Free/`2`=Pro, `null` on legacy responses; per-tier limits in [quota-limits.md](quota-limits.md)), and global `output_language` for quota pacing + language context (best-effort; identity is network-free from the profile, the quota fields need a live session). `email` is real account PII, returned only under this opt-in flag |
Tools that only read are annotated read-only; the destructive tools (the three `*_delete` tools plus `share_remove_user`) are annotated destructive
and require `confirm`. A host that honors MCP annotations can auto-allow the read-only calls and
gate the destructive ones.
## Troubleshooting
- **`AUTH` errors / "not authenticated".** Run `notebooklm login` (or `notebooklm -p <profile> login`)
in a terminal, then restart the server. Check with the `server_info` tool, which reports auth health.
- **`uvx` / `uv` not found.** Install uv: `curl -LsSf https://astral.sh/uv/install.sh | sh` (macOS/Linux)
or `powershell -c "irm https://astral.sh/uv/install.ps1 | iex"` (Windows). The desktop launcher also
searches common install dirs beyond `PATH`.
- **Client doesn't see the tools.** Confirm the config was written (`notebooklm mcp install <client>`)
and **restart the client** — most hosts only read MCP config at startup.
- **Wrong account.** The server binds one profile per process. Start it with `--profile <name>`, or set
`NOTEBOOKLM_PROFILE`. See [configuration.md](configuration.md#multiple-accounts).
- **`RATE_LIMITED`.** NotebookLM enforces per-account quotas; the error is `retriable=true` — back off
and retry.
## See also
- [installation.md](installation.md#running-the-mcp-server-mcp-extra) — the `mcp` extra + run/connect summary
- [`desktop-extension/README.md`](../desktop-extension/README.md) — one-click Claude Desktop `.mcpb` bundle (prebuilt, attached to each stable release)
- [configuration.md](configuration.md) — profiles, multi-account, storage
- [cli-reference.md](cli-reference.md) — the equivalent CLI commands