Files
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

170 lines
6.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Resilient launcher for the NotebookLM MCP server in Claude Desktop.
Claude Desktop (and similar MCP hosts) often run with a *restricted* ``PATH``
that omits user-local install dirs, so ``uvx`` may not be discoverable via the
shell ``PATH`` alone. This launcher searches the common install locations before
falling back to ``PATH``, then execs::
uvx --from "notebooklm-py[mcp]" notebooklm-mcp [<forwarded args>]
forwarding the host's stdin/stdout/stderr through cleanly. The stdio passthrough
is critical: the MCP host speaks JSON-RPC over the child's stdin/stdout, so this
launcher must never write its own bytes to stdout. Diagnostics go to STDERR
only, and a missing ``uvx`` exits non-zero with a clear message.
Bundled inside the ``.mcpb`` extension and invoked via ``manifest.json``::
"command": "python3", "args": ["${__dirname}/run_server.py"]
The module exposes ``find_uvx`` / ``build_command`` / ``main`` as importable
functions so the bundle can be unit-tested without execing anything.
"""
from __future__ import annotations
import json
import os
import platform
import re
import shutil
import subprocess
import sys
#: The PyPI distribution + ``mcp`` extra to resolve, and the console script to
#: run. ``uvx --from "<PACKAGE>" <CONSOLE_SCRIPT>`` fetches the package (with the
#: extra) into an ephemeral environment and runs the script.
PACKAGE = "notebooklm-py[mcp]"
CONSOLE_SCRIPT = "notebooklm-mcp"
def _bundle_version() -> str | None:
"""Read this bundle's version from the sibling ``manifest.json``.
Returns the version string, or ``None`` when the manifest is missing or
unreadable (a defensive fallback — a bundle always ships ``manifest.json``
beside this launcher).
"""
manifest = os.path.join(os.path.dirname(os.path.abspath(__file__)), "manifest.json")
try:
with open(manifest, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError):
return None
version = data.get("version") if isinstance(data, dict) else None
return version if isinstance(version, str) and version else None
def _is_prerelease(version: str) -> bool:
"""True for a PEP 440 pre-release or development version.
Covers ``…aN`` / ``…bN`` / ``…rcN`` and the alternate spellings
(``alpha`` / ``beta`` / ``c`` / ``pre`` / ``preview``), a trailing ``.devN``
(including on top of a pre-release, e.g. ``0.8.0a1.dev0``), and implicit-zero
forms (``0.8.0a``). A clean or ``.postN`` release is not a pre-release.
"""
return (
re.search(
r"[-._]?(?:a|b|rc|alpha|beta|c|pre|preview|dev)[-._]?\d*(?:[-._]?dev[-._]?\d*)?$",
version,
)
is not None
)
def _candidate_uvx_paths() -> list[str]:
"""Return common ``uvx`` install locations for the current platform."""
home = os.path.expanduser("~")
system = platform.system()
if system == "Windows":
appdata = os.environ.get("LOCALAPPDATA", os.path.join(home, "AppData", "Local"))
return [
os.path.join(home, ".local", "bin", "uvx.exe"),
os.path.join(home, ".cargo", "bin", "uvx.exe"),
os.path.join(appdata, "uv", "uvx.exe"),
os.path.join(home, "scoop", "shims", "uvx.exe"),
]
# POSIX (macOS + Linux): the uv installer drops uvx in ~/.local/bin or
# ~/.cargo/bin; Homebrew on Apple Silicon uses /opt/homebrew/bin; Intel macOS
# and most Linux package managers use /usr/local/bin. /snap/bin covers the
# Ubuntu snap.
return [
os.path.join(home, ".local", "bin", "uvx"),
os.path.join(home, ".cargo", "bin", "uvx"),
"/opt/homebrew/bin/uvx",
"/usr/local/bin/uvx",
"/snap/bin/uvx",
]
def find_uvx() -> str | None:
"""Locate the ``uvx`` executable.
Checks ``PATH`` first (honors a host that already exposes ``uvx``), then the
common per-platform install locations. Returns the absolute path, or
``None`` when ``uvx`` cannot be found anywhere.
"""
on_path = shutil.which("uvx")
if on_path:
return on_path
for candidate in _candidate_uvx_paths():
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
def build_command(uvx: str, argv: list[str], version: str | None = None) -> list[str]:
"""Build the ``uvx`` exec argv, forwarding ``argv`` to the console script.
A **stable** bundle stays unpinned so it tracks the latest stable server. A
**pre-release** bundle pins its exact ``version`` (e.g.
``notebooklm-py[mcp]==0.8.0a1``) so ``uvx`` launches that pre-release rather
than the latest stable. The explicit ``==`` pre-release pin is enough — uv's
default resolver accepts a pre-release requested by an explicit marker — so
no ``--prerelease`` flag is used; ``--prerelease=allow`` would needlessly let
*transitive* dependencies resolve to pre-releases too, hurting reproducibility.
"""
spec = f"{PACKAGE}=={version}" if version is not None and _is_prerelease(version) else PACKAGE
return [uvx, "--from", spec, CONSOLE_SCRIPT, *argv]
def main() -> None:
"""Find ``uvx`` and exec the NotebookLM MCP server, or fail cleanly."""
uvx = find_uvx()
if not uvx:
# STDERR only — stdout is the JSON-RPC channel and must stay pristine.
print(
"Error: could not find 'uvx'. Install uv first:\n"
" curl -LsSf https://astral.sh/uv/install.sh | sh\n"
' # Windows: powershell -c "irm https://astral.sh/uv/install.ps1 | iex"\n'
"\n"
"Then restart your MCP host (e.g. Claude Desktop).",
file=sys.stderr,
)
sys.exit(1)
# Explicit stdin/stdout/stderr passthrough is critical: the MCP host
# communicates with the server via stdin/stdout JSON-RPC. We do NOT capture
# them — they flow straight through to the child process.
cmd = build_command(uvx, sys.argv[1:], _bundle_version())
try:
result = subprocess.run( # noqa: S603 - argv is constructed, not shell-interpolated
cmd,
stdin=sys.stdin,
stdout=sys.stdout,
stderr=sys.stderr,
check=False,
)
except KeyboardInterrupt:
sys.exit(130)
sys.exit(result.returncode)
if __name__ == "__main__":
main()