Skip to content

Commit 665492c

Browse files
committed
feat: bulk sharing - invite multiple collaborators in one API call (Issue #73)
- Core: add_collaborators_bulk() on SharingMixin - Services: invite_collaborators_bulk() with upfront validation - MCP: notebook_share_batch tool with recipients list + confirm - CLI: nlm share batch <notebook> 'emails' --role viewer - 10 new unit tests for bulk sharing - Version bump to 0.4.0
1 parent 61cd24f commit 665492c

11 files changed

Lines changed: 1203 additions & 878 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,18 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7-
## [Unreleased / 0.3.21]
7+
## [0.4.0] - 2026-03-05
8+
9+
### Added
10+
- **Bulk Sharing (`notebook_share_batch`)** — Invite multiple collaborators to a notebook in a single API call (Issue #73). Supports mixed roles (viewer/editor) per recipient.
11+
- Core: `add_collaborators_bulk(notebook_id, recipients)` on `SharingMixin`
12+
- Service: `invite_collaborators_bulk(client, notebook_id, recipients)` with upfront validation
13+
- MCP: `notebook_share_batch` tool with `recipients` list and `confirm` flag
14+
- CLI: `nlm share batch <notebook> "a@gmail.com,b@gmail.com" --role viewer`
15+
- **10 new unit tests** for bulk sharing (core + services)
816

917
### Fixed
10-
- **Version mismatch (Patch)** — Bump internal `__version__` string in `__init__.py` to correctly report as `0.3.21` (was omitted in `0.3.20` release).
18+
- **Version mismatch (Patch)** — Bump internal `__version__` string in `__init__.py` to correctly report version (was omitted in `0.3.20` release).
1119

1220
## [0.3.20] - 2026-03-04
1321

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "notebooklm-mcp-cli"
7-
version = "0.3.20"
7+
version = "0.4.0"
88
description = "Unified CLI and MCP server for Google NotebookLM"
99
readme = "README.md"
1010
license = "MIT"

src/notebooklm_tools/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""NotebookLM Tools - Unified CLI and MCP server for Google NotebookLM."""
22

3-
__version__ = "0.3.20"
3+
__version__ = "0.4.0"
44

55
from notebooklm_tools.core.client import NotebookLMClient
66

src/notebooklm_tools/cli/commands/share.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,50 @@ def share_invite(
138138
if e.hint:
139139
console.print(f"\n[dim]Hint: {e.hint}[/dim]")
140140
raise typer.Exit(1)
141+
142+
143+
@app.command("batch")
144+
def share_batch(
145+
notebook: str = typer.Argument(..., help="Notebook ID or alias"),
146+
emails: str = typer.Argument(..., help="Comma-separated email addresses"),
147+
role: str = typer.Option("viewer", "--role", "-r", help="Role for all: viewer or editor"),
148+
profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Profile to use"),
149+
) -> None:
150+
"""Invite multiple collaborators at once.
151+
152+
Example: nlm share batch <notebook> "a@gmail.com,b@gmail.com" --role viewer
153+
"""
154+
try:
155+
notebook_id = get_alias_manager().resolve(notebook)
156+
157+
# Parse comma-separated emails into recipients list
158+
email_list = [e.strip() for e in emails.split(",") if e.strip()]
159+
if not email_list:
160+
console.print("[red]Error:[/red] No valid email addresses provided.")
161+
raise typer.Exit(1)
162+
163+
recipients = [{"email": e, "role": role} for e in email_list]
164+
165+
with get_client(profile) as client:
166+
result = sharing_service.invite_collaborators_bulk(client, notebook_id, recipients)
167+
168+
console.print(f"[green]✓[/green] {result['message']}")
169+
170+
table = Table(show_header=True, header_style="bold")
171+
table.add_column("Email")
172+
table.add_column("Role")
173+
174+
for r in result["recipients"]:
175+
role_color = "cyan" if r["role"] == "editor" else "dim"
176+
table.add_row(r["email"], f"[{role_color}]{r['role']}[/{role_color}]")
177+
178+
console.print(table)
179+
180+
except ServiceError as e:
181+
console.print(f"[red]Error:[/red] {e.user_message}")
182+
raise typer.Exit(1)
183+
except NLMError as e:
184+
console.print(f"[red]Error:[/red] {e.message}")
185+
if e.hint:
186+
console.print(f"\n[dim]Hint: {e.hint}[/dim]")
187+
raise typer.Exit(1)

src/notebooklm_tools/core/sharing.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- get_share_status: Get current collaborators and public access
55
- set_public_access: Toggle public link access
66
- add_collaborator: Add a collaborator by email
7+
- add_collaborators_bulk: Add multiple collaborators in a single API call
78
"""
89

910
from .base import BaseClient
@@ -150,3 +151,51 @@ def add_collaborator(
150151

151152
# Success if result is not None (no error thrown)
152153
return result is not None
154+
155+
def add_collaborators_bulk(
156+
self,
157+
notebook_id: str,
158+
recipients: list[dict],
159+
notify: bool = True,
160+
message: str = "",
161+
) -> bool:
162+
"""Add multiple collaborators to a notebook in a single API call.
163+
164+
Args:
165+
notebook_id: The notebook UUID
166+
recipients: List of dicts, each with 'email' (str) and 'role' (str).
167+
Role must be 'viewer' or 'editor'.
168+
notify: Send email notification (default: True)
169+
message: Optional welcome message
170+
171+
Returns:
172+
True if successful
173+
174+
Raises:
175+
ValueError: If any role is invalid or recipients list is empty
176+
"""
177+
if not recipients:
178+
raise ValueError("Recipients list cannot be empty")
179+
180+
# Build the multi-email array: [[email1, None, role_code1], [email2, None, role_code2], ...]
181+
email_items = []
182+
for recipient in recipients:
183+
email = recipient["email"]
184+
role = recipient.get("role", "viewer")
185+
role_code = constants.SHARE_ROLES.get_code(role)
186+
if role_code == constants.SHARE_ROLE_OWNER:
187+
raise ValueError(f"Cannot add collaborator '{email}' as owner")
188+
email_items.append([email, None, role_code])
189+
190+
notify_flag = 0 if notify else 1 # 0 = notify, 1 = don't notify
191+
192+
params = [
193+
[[notebook_id, email_items, None, [notify_flag, message]]],
194+
1,
195+
None,
196+
[2]
197+
]
198+
199+
result = self._call_rpc(self.RPC_SHARE_NOTEBOOK, params)
200+
201+
return result is not None

src/notebooklm_tools/mcp/tools/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
notebook_share_status,
2424
notebook_share_public,
2525
notebook_share_invite,
26+
notebook_share_batch,
2627
)
2728
from .research import (
2829
research_start,
@@ -65,10 +66,11 @@
6566
"source_delete",
6667
"source_describe",
6768
"source_get_content",
68-
# Sharing (3)
69+
# Sharing (4)
6970
"notebook_share_status",
7071
"notebook_share_public",
7172
"notebook_share_invite",
73+
"notebook_share_batch",
7274
# Research (3)
7375
"research_start",
7476
"research_status",

src/notebooklm_tools/mcp/tools/sharing.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,35 @@ def notebook_share_invite(
7171
return {"status": "error", "error": e.user_message}
7272
except Exception as e:
7373
return {"status": "error", "error": str(e)}
74+
75+
76+
@logged_tool()
77+
def notebook_share_batch(
78+
notebook_id: str,
79+
recipients: list[dict],
80+
confirm: bool = False,
81+
) -> dict[str, Any]:
82+
"""Invite multiple collaborators in a single request.
83+
84+
Args:
85+
notebook_id: Notebook UUID
86+
recipients: List of dicts, each with 'email' (str) and optional 'role' (str).
87+
Role defaults to 'viewer'. Example: [{"email": "a@b.com", "role": "editor"}]
88+
confirm: Must be True after user approval
89+
90+
Returns: invited_count, recipients list, and message
91+
"""
92+
if not confirm:
93+
return {
94+
"status": "confirmation_required",
95+
"message": f"About to invite {len(recipients)} collaborators. Set confirm=True to proceed.",
96+
"recipients": recipients,
97+
}
98+
try:
99+
client = get_client()
100+
result = sharing_service.invite_collaborators_bulk(client, notebook_id, recipients)
101+
return {"status": "success", **result}
102+
except ServiceError as e:
103+
return {"status": "error", "error": e.user_message}
104+
except Exception as e:
105+
return {"status": "error", "error": str(e)}

src/notebooklm_tools/services/sharing.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@ class InviteResult(TypedDict):
4141
message: str
4242

4343

44+
class RecipientInfo(TypedDict):
45+
"""Individual recipient in a bulk invite."""
46+
email: str
47+
role: str
48+
49+
50+
class BulkInviteResult(TypedDict):
51+
"""Result of a bulk collaborator invitation."""
52+
notebook_id: str
53+
invited_count: int
54+
recipients: list[RecipientInfo]
55+
message: str
56+
57+
4458
def _collaborator_to_dict(c: Collaborator) -> CollaboratorInfo:
4559
"""Convert a Collaborator dataclass to a dict."""
4660
return {
@@ -162,3 +176,66 @@ def invite_collaborator(
162176
raise
163177
except Exception as e:
164178
raise ServiceError(f"Failed to invite collaborator: {e}")
179+
180+
181+
def invite_collaborators_bulk(
182+
client: NotebookLMClient,
183+
notebook_id: str,
184+
recipients: list[dict],
185+
) -> BulkInviteResult:
186+
"""Invite multiple collaborators in a single API call.
187+
188+
Args:
189+
client: Authenticated NotebookLM client
190+
notebook_id: Notebook UUID
191+
recipients: List of dicts, each with 'email' (str) and optional 'role' (str).
192+
Role defaults to 'viewer' if not specified.
193+
194+
Returns:
195+
BulkInviteResult with invitation summary
196+
197+
Raises:
198+
ValidationError: If recipients list is empty or any role is invalid
199+
ServiceError: If the API call fails
200+
"""
201+
if not recipients:
202+
raise ValidationError(
203+
"Recipients list is empty.",
204+
user_message="You must provide at least one email address.",
205+
)
206+
207+
# Validate all roles upfront before making the API call
208+
cleaned_recipients: list[RecipientInfo] = []
209+
for recipient in recipients:
210+
email = recipient.get("email", "").strip()
211+
if not email:
212+
raise ValidationError(
213+
"Empty email in recipients list.",
214+
user_message="Each recipient must have a non-empty email address.",
215+
)
216+
role = recipient.get("role", "viewer").lower()
217+
if role not in ("viewer", "editor"):
218+
raise ValidationError(
219+
f"Invalid role '{role}' for {email}. Must be 'viewer' or 'editor'.",
220+
user_message=f"Role must be 'viewer' or 'editor' (got '{role}' for {email})",
221+
)
222+
cleaned_recipients.append({"email": email, "role": role})
223+
224+
try:
225+
result = client.add_collaborators_bulk(notebook_id, cleaned_recipients)
226+
if result:
227+
emails_str = ", ".join(r["email"] for r in cleaned_recipients)
228+
return {
229+
"notebook_id": notebook_id,
230+
"invited_count": len(cleaned_recipients),
231+
"recipients": cleaned_recipients,
232+
"message": f"Invited {len(cleaned_recipients)} collaborators: {emails_str}",
233+
}
234+
raise ServiceError(
235+
"Bulk invitation returned falsy result",
236+
user_message="Bulk invitation may have failed — no confirmation from API.",
237+
)
238+
except ServiceError:
239+
raise
240+
except Exception as e:
241+
raise ServiceError(f"Failed to invite collaborators: {e}")

tests/core/test_sharing.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def test_sharing_mixin_has_methods():
2525
'get_share_status',
2626
'set_public_access',
2727
'add_collaborator',
28+
'add_collaborators_bulk',
2829
]
2930

3031
for method_name in expected_methods:
@@ -79,3 +80,53 @@ def test_add_collaborator_uses_correct_rpc():
7980
call_args = mock_rpc.call_args
8081
assert call_args[0][0] == "QDyure" # RPC_SHARE_NOTEBOOK
8182
assert result is True
83+
84+
85+
def test_add_collaborators_bulk_uses_correct_rpc():
86+
"""Test that add_collaborators_bulk calls the correct RPC with multi-email payload."""
87+
from notebooklm_tools.core.sharing import SharingMixin
88+
89+
with patch.object(SharingMixin, '_refresh_auth_tokens'):
90+
with patch.object(SharingMixin, '_call_rpc') as mock_rpc:
91+
mock_rpc.return_value = []
92+
93+
mixin = SharingMixin(cookies={"test": "cookie"}, csrf_token="test")
94+
recipients = [
95+
{"email": "alice@example.com", "role": "viewer"},
96+
{"email": "bob@example.com", "role": "editor"},
97+
]
98+
result = mixin.add_collaborators_bulk("notebook_id_123", recipients)
99+
100+
mock_rpc.assert_called_once()
101+
call_args = mock_rpc.call_args
102+
assert call_args[0][0] == "QDyure" # RPC_SHARE_NOTEBOOK
103+
104+
# Verify the multi-email array structure
105+
params = call_args[0][1]
106+
email_items = params[0][0][1] # [[email, None, role_code], ...]
107+
assert len(email_items) == 2
108+
assert email_items[0][0] == "alice@example.com"
109+
assert email_items[0][2] == 3 # SHARE_ROLE_VIEWER
110+
assert email_items[1][0] == "bob@example.com"
111+
assert email_items[1][2] == 2 # SHARE_ROLE_EDITOR
112+
assert result is True
113+
114+
115+
def test_add_collaborators_bulk_empty_recipients():
116+
"""Test that add_collaborators_bulk raises ValueError for empty list."""
117+
from notebooklm_tools.core.sharing import SharingMixin
118+
119+
with patch.object(SharingMixin, '_refresh_auth_tokens'):
120+
mixin = SharingMixin(cookies={"test": "cookie"}, csrf_token="test")
121+
with pytest.raises(ValueError, match="Recipients list cannot be empty"):
122+
mixin.add_collaborators_bulk("notebook_id_123", [])
123+
124+
125+
def test_add_collaborators_bulk_rejects_owner_role():
126+
"""Test that add_collaborators_bulk raises ValueError for owner role."""
127+
from notebooklm_tools.core.sharing import SharingMixin
128+
129+
with patch.object(SharingMixin, '_refresh_auth_tokens'):
130+
mixin = SharingMixin(cookies={"test": "cookie"}, csrf_token="test")
131+
with pytest.raises(ValueError, match="Cannot add collaborator"):
132+
mixin.add_collaborators_bulk("notebook_id_123", [{"email": "a@b.com", "role": "owner"}])

0 commit comments

Comments
 (0)