perplexity-web-mcp-cli 0.14.2__tar.gz → 0.14.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/PKG-INFO +10 -2
  2. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/README.md +8 -1
  3. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/pyproject.toml +2 -1
  4. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/api/server.py +2 -2
  5. perplexity_web_mcp_cli-0.14.4/src/perplexity_web_mcp/auth.py +153 -0
  6. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/cli/ai_doc.py +6 -3
  7. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/cli/auth.py +53 -64
  8. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/cli/setup.py +6 -1
  9. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/constants.py +7 -0
  10. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/core.py +6 -4
  11. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/data/SKILL.md +8 -4
  12. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/data/references/mcp-tools.md +4 -1
  13. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/mcp/server.py +31 -60
  14. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/rate_limits.py +14 -2
  15. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/shared.py +6 -0
  16. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/__init__.py +0 -0
  17. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/api/__init__.py +0 -0
  18. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/api/responses.py +0 -0
  19. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/api/session_manager.py +0 -0
  20. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/api/tool_calling.py +0 -0
  21. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/cli/__init__.py +0 -0
  22. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/cli/doctor.py +0 -0
  23. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/cli/hack.py +0 -0
  24. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/cli/main.py +0 -0
  25. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/cli/skill.py +0 -0
  26. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/config.py +0 -0
  27. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/council.py +0 -0
  28. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/data/references/api-endpoints.md +0 -0
  29. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/data/references/models.md +0 -0
  30. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/enums.py +0 -0
  31. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/exceptions.py +0 -0
  32. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/http.py +0 -0
  33. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/limits.py +0 -0
  34. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/logging.py +0 -0
  35. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/mcp/__init__.py +0 -0
  36. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/mcp/__main__.py +0 -0
  37. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/models.py +0 -0
  38. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/py.typed +0 -0
  39. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/resilience.py +0 -0
  40. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/router.py +0 -0
  41. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/sessions.py +0 -0
  42. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/token_store.py +0 -0
  43. {perplexity_web_mcp_cli-0.14.2 → perplexity_web_mcp_cli-0.14.4}/src/perplexity_web_mcp/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: perplexity-web-mcp-cli
3
- Version: 0.14.2
3
+ Version: 0.14.4
4
4
  Summary: CLI, MCP server, and Anthropic/OpenAI API-compatible interface for Perplexity AI.
5
5
  Keywords: perplexity,ai,mcp,anthropic,api,client
6
6
  Author: Jacob BD
@@ -22,6 +22,7 @@ Requires-Dist: loguru>=0.7.3,<1.0
22
22
  Requires-Dist: orjson>=3.11.6,<4.0
23
23
  Requires-Dist: pydantic>=2.12.5,<3.0
24
24
  Requires-Dist: tenacity>=9.1.2,<10.0
25
+ Requires-Dist: tomli>=2.0.0,<3.0 ; python_full_version < '3.11'
25
26
  Requires-Dist: python-dotenv>=1.0.0,<2.0
26
27
  Requires-Dist: rich>=13.0.0,<15.0
27
28
  Requires-Dist: rich-click>=1.8
@@ -269,8 +270,12 @@ pwm login # Interactive login (email + OTP)
269
270
  pwm login --check # Check if authenticated
270
271
  pwm login --email user@example.com # Send verification code (non-interactive)
271
272
  pwm login --email user@example.com --code 123456 # Complete auth with code
273
+ pwm login --email user@example.com --code 123456 --totp-code 654321 # Account with TOTP enabled
272
274
  ```
273
275
 
276
+ Set `PWM_SAVE_TO_LIBRARY=1` to save shared CLI and MCP queries to the Perplexity thread library. Queries are
277
+ incognito by default.
278
+
274
279
  ### Usage & Limits
275
280
 
276
281
  ```bash
@@ -455,7 +460,7 @@ claude mcp add perplexity pwm-mcp
455
460
  | `pplx_connectors` | List connector source IDs |
456
461
  | `pplx_auth_status` | Check authentication status |
457
462
  | `pplx_auth_request_code` | Send verification code to email |
458
- | `pplx_auth_complete` | Complete auth with 6-digit code |
463
+ | `pplx_auth_complete` | Complete email and TOTP authentication |
459
464
 
460
465
  All query tools support `source_focus`: `none`, `web`, `academic`, `social`, `finance`, `all`, or a connector source ID returned by `pplx_connectors()`.
461
466
 
@@ -586,6 +591,9 @@ pwm login --email your@email.com --code 123456
586
591
  2. Check email for 6-digit code
587
592
  3. Call `pplx_auth_complete(email="your@email.com", code="123456")`
588
593
 
594
+ If the tool returns `TOTP_REQUIRED`, call it again with
595
+ `pplx_auth_complete(email="your@email.com", totp_code="654321")`.
596
+
589
597
  ### Diagnose Issues
590
598
 
591
599
  ```bash
@@ -232,8 +232,12 @@ pwm login # Interactive login (email + OTP)
232
232
  pwm login --check # Check if authenticated
233
233
  pwm login --email user@example.com # Send verification code (non-interactive)
234
234
  pwm login --email user@example.com --code 123456 # Complete auth with code
235
+ pwm login --email user@example.com --code 123456 --totp-code 654321 # Account with TOTP enabled
235
236
  ```
236
237
 
238
+ Set `PWM_SAVE_TO_LIBRARY=1` to save shared CLI and MCP queries to the Perplexity thread library. Queries are
239
+ incognito by default.
240
+
237
241
  ### Usage & Limits
238
242
 
239
243
  ```bash
@@ -418,7 +422,7 @@ claude mcp add perplexity pwm-mcp
418
422
  | `pplx_connectors` | List connector source IDs |
419
423
  | `pplx_auth_status` | Check authentication status |
420
424
  | `pplx_auth_request_code` | Send verification code to email |
421
- | `pplx_auth_complete` | Complete auth with 6-digit code |
425
+ | `pplx_auth_complete` | Complete email and TOTP authentication |
422
426
 
423
427
  All query tools support `source_focus`: `none`, `web`, `academic`, `social`, `finance`, `all`, or a connector source ID returned by `pplx_connectors()`.
424
428
 
@@ -549,6 +553,9 @@ pwm login --email your@email.com --code 123456
549
553
  2. Check email for 6-digit code
550
554
  3. Call `pplx_auth_complete(email="your@email.com", code="123456")`
551
555
 
556
+ If the tool returns `TOTP_REQUIRED`, call it again with
557
+ `pplx_auth_complete(email="your@email.com", totp_code="654321")`.
558
+
552
559
  ### Diagnose Issues
553
560
 
554
561
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "perplexity-web-mcp-cli"
3
- version = "0.14.2"
3
+ version = "0.14.4"
4
4
  description = "CLI, MCP server, and Anthropic/OpenAI API-compatible interface for Perplexity AI."
5
5
  authors = [{ name = "Jacob BD" }]
6
6
  license = "MIT"
@@ -27,6 +27,7 @@ dependencies = [
27
27
  "orjson>=3.11.6,<4.0",
28
28
  "pydantic>=2.12.5,<3.0",
29
29
  "tenacity>=9.1.2,<10.0",
30
+ "tomli>=2.0.0,<3.0; python_version < '3.11'",
30
31
  "python-dotenv>=1.0.0,<2.0",
31
32
  "rich>=13.0.0,<15.0",
32
33
  "rich-click>=1.8",
@@ -1075,8 +1075,8 @@ async def stream_response(
1075
1075
 
1076
1076
  elif kind == "error":
1077
1077
  logging.error(f"Stream error: {payload}")
1078
- # Add recovery instructions for 403 errors
1079
- error_msg = payload
1078
+ # Strip wrapper prefixes for cleaner client-facing messages
1079
+ error_msg = payload.removeprefix("Failed to parse API response: ")
1080
1080
  if "403" in payload or "forbidden" in payload.lower():
1081
1081
  error_msg = (
1082
1082
  "Session token expired (403). "
@@ -0,0 +1,153 @@
1
+ """Perplexity email and TOTP authentication protocol helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from urllib.parse import parse_qs, urlparse
7
+
8
+ from curl_cffi.requests import Session
9
+
10
+ from .constants import API_BASE_URL, API_VERSION, APP_HEADERS, SESSION_COOKIE_NAME
11
+
12
+
13
+ AUTH_CSRF_ENDPOINT = "/api/auth/csrf"
14
+ AUTH_OTP_REDIRECT_ENDPOINT = "/api/auth/otp-redirect-link"
15
+ AUTH_SIGNIN_ENDPOINT = "/api/auth/signin/email"
16
+ AUTH_TOTP_VERIFY_ENDPOINT = "/api/auth/totp-challenge/verify"
17
+ _REDIRECT_STATUSES = {301, 302, 303, 307, 308}
18
+
19
+
20
+ def create_auth_session() -> tuple[Session, str]:
21
+ """Create a browser-like session and return it with a CSRF token."""
22
+ session = Session(
23
+ impersonate="chrome",
24
+ headers={**APP_HEADERS, "Referer": API_BASE_URL, "Origin": API_BASE_URL},
25
+ )
26
+ session.get(API_BASE_URL)
27
+ response = session.get(f"{API_BASE_URL}{AUTH_CSRF_ENDPOINT}")
28
+
29
+ if response.status_code != 200:
30
+ raise ValueError(f"Failed to obtain CSRF token: HTTP {response.status_code}.")
31
+
32
+ csrf = _response_json(response).get("csrfToken")
33
+ if not csrf:
34
+ raise ValueError("Failed to obtain CSRF token.")
35
+
36
+ return session, str(csrf)
37
+
38
+
39
+ def request_verification_code(session: Session, csrf: str, email: str) -> None:
40
+ """Send an email verification code using an initialized auth session."""
41
+ response = session.post(
42
+ f"{API_BASE_URL}{AUTH_SIGNIN_ENDPOINT}?version={API_VERSION}&source=default",
43
+ json={
44
+ "email": email,
45
+ "csrfToken": csrf,
46
+ "useNumericOtp": "true",
47
+ "json": "true",
48
+ "callbackUrl": f"{API_BASE_URL}/?login-source=floatingSignup",
49
+ },
50
+ )
51
+
52
+ if response.status_code != 200:
53
+ raise ValueError(f"Authentication request failed: {response.text}")
54
+
55
+
56
+ def resolve_redirect_url(session: Session, email: str, code_or_link: str) -> str:
57
+ """Resolve an email OTP or magic link to the authentication callback URL."""
58
+ if code_or_link.startswith("http"):
59
+ return code_or_link
60
+
61
+ response = session.post(
62
+ f"{API_BASE_URL}{AUTH_OTP_REDIRECT_ENDPOINT}",
63
+ json={
64
+ "email": email,
65
+ "otp": code_or_link,
66
+ "redirectUrl": f"{API_BASE_URL}/?login-source=floatingSignup",
67
+ "emailLoginMethod": "web-otp",
68
+ },
69
+ )
70
+
71
+ if response.status_code != 200:
72
+ raise ValueError("Invalid verification code.")
73
+
74
+ redirect_path = _response_json(response).get("redirect")
75
+ if not redirect_path:
76
+ raise ValueError("No redirect URL received.")
77
+
78
+ redirect = str(redirect_path)
79
+ return f"{API_BASE_URL}{redirect}" if redirect.startswith("/") else redirect
80
+
81
+
82
+ def follow_auth_callback(session: Session, redirect_url: str) -> str | None:
83
+ """Follow an auth callback, returning a challenge token when TOTP is required."""
84
+ response = session.get(redirect_url, allow_redirects=False)
85
+ if response.status_code not in _REDIRECT_STATUSES:
86
+ return None
87
+
88
+ location = response.headers.get("Location", "")
89
+ if not location:
90
+ raise ValueError("Authentication callback did not provide a redirect location.")
91
+ if "error=" in location:
92
+ raise ValueError("Verification failed. The code may be invalid or expired.")
93
+
94
+ if "/auth/totp-challenge" in location:
95
+ absolute_location = location if location.startswith("http") else f"{API_BASE_URL}{location}"
96
+ challenge_token = parse_qs(urlparse(absolute_location).query).get("token", [""])[0]
97
+ if not challenge_token:
98
+ raise ValueError("TOTP challenge token not found in redirect.")
99
+ return challenge_token
100
+
101
+ session.get(location if location.startswith("http") else f"{API_BASE_URL}{location}")
102
+ return None
103
+
104
+
105
+ def verify_totp(session: Session, challenge_token: str, totp_code: str) -> None:
106
+ """Complete a TOTP challenge and follow its post-verification redirect."""
107
+ if not totp_code.isdigit() or len(totp_code) != 6:
108
+ raise ValueError("TOTP code must be a 6-digit number.")
109
+
110
+ response = session.post(
111
+ f"{API_BASE_URL}{AUTH_TOTP_VERIFY_ENDPOINT}?version={API_VERSION}&source=default",
112
+ json={"token": challenge_token, "code": totp_code},
113
+ allow_redirects=False,
114
+ )
115
+ data = _response_json(response)
116
+
117
+ if response.status_code >= 400 or data.get("error"):
118
+ detail = data.get("error") or f"HTTP {response.status_code}"
119
+ raise ValueError(f"TOTP verification failed: {detail}")
120
+
121
+ location = response.headers.get("Location", "") if response.status_code in _REDIRECT_STATUSES else ""
122
+ redirect = location or str(data.get("redirect", ""))
123
+ if redirect:
124
+ session.get(redirect if redirect.startswith("http") else f"{API_BASE_URL}{redirect}")
125
+
126
+
127
+ def extract_session_token(session: Session) -> str:
128
+ """Return the session token, including a token split across cookie chunks."""
129
+ token = session.cookies.get(SESSION_COOKIE_NAME)
130
+ if token:
131
+ return token
132
+
133
+ chunks = sorted(
134
+ [
135
+ (name, value)
136
+ for name, value in session.cookies.items()
137
+ if name.startswith(f"{SESSION_COOKIE_NAME}.") and name.removeprefix(f"{SESSION_COOKIE_NAME}.").isdigit()
138
+ ],
139
+ key=lambda item: int(item[0].removeprefix(f"{SESSION_COOKIE_NAME}.")),
140
+ )
141
+ if chunks:
142
+ return "".join(value for _, value in chunks)
143
+
144
+ raise ValueError("Authentication completed, but the session token cookie was not returned.")
145
+
146
+
147
+ def _response_json(response: Any) -> dict[str, Any]:
148
+ """Return a JSON object from a response, or an empty object for non-JSON bodies."""
149
+ try:
150
+ data = response.json()
151
+ except Exception:
152
+ return {}
153
+ return data if isinstance(data, dict) else {}
@@ -240,8 +240,8 @@ AUTH TOOLS (3):
240
240
  pplx_auth_request_code(email)
241
241
  Send 6-digit verification code to email.
242
242
 
243
- pplx_auth_complete(email, code)
244
- Complete auth with code from email. Saves token automatically.
243
+ pplx_auth_complete(email, code, totp_code=None)
244
+ Complete email OTP and optional TOTP authentication. Saves token automatically.
245
245
 
246
246
  ================================================================================
247
247
  AUTHENTICATION
@@ -255,10 +255,13 @@ Three ways to authenticate (all store token at ~/.config/perplexity-web-mcp/toke
255
255
  2. NON-INTERACTIVE CLI (AI agent with shell access):
256
256
  pwm login --email user@example.com # Sends code
257
257
  pwm login --email user@example.com --code 123456 # Completes auth
258
+ pwm login --email user@example.com --code 123456 --totp-code 654321 # TOTP account
258
259
 
259
260
  3. MCP TOOLS (AI agent without shell):
260
261
  pplx_auth_request_code(email="user@example.com") # Sends code
261
- pplx_auth_complete(email="user@example.com", code="123456") # Completes
262
+ pplx_auth_complete(email="user@example.com", code="123456") # Completes email OTP
263
+ # If TOTP_REQUIRED is returned:
264
+ pplx_auth_complete(email="user@example.com", totp_code="654321")
262
265
 
263
266
  Tokens last ~30 days. Re-authenticate when you get 403 errors.
264
267
  Check status: pwm login --check OR pplx_auth_status()
@@ -8,18 +8,25 @@ from sys import exit
8
8
  from typing import NoReturn
9
9
 
10
10
  from curl_cffi.requests import Session
11
- from orjson import loads
12
11
  from rich.console import Console
13
12
  from rich.panel import Panel
14
13
  from rich.prompt import Prompt
15
14
  from rich.table import Table
16
15
 
16
+ from perplexity_web_mcp.auth import (
17
+ create_auth_session,
18
+ extract_session_token,
19
+ follow_auth_callback,
20
+ request_verification_code,
21
+ resolve_redirect_url,
22
+ verify_totp,
23
+ )
24
+ from perplexity_web_mcp.constants import API_BASE_URL, APP_HEADERS, SESSION_COOKIE_NAME
17
25
  from perplexity_web_mcp.token_store import load_token
18
26
  from perplexity_web_mcp.token_store import save_token as save_token_to_config
19
27
 
20
28
 
21
- BASE_URL: str = "https://www.perplexity.ai"
22
- SESSION_COOKIE_NAME: str = "__Secure-next-auth.session-token"
29
+ BASE_URL: str = API_BASE_URL
23
30
 
24
31
  console = Console(stderr=True, soft_wrap=True)
25
32
 
@@ -96,7 +103,7 @@ def get_user_info(token: str) -> UserInfo | None:
96
103
  try:
97
104
  with Session(
98
105
  impersonate="chrome",
99
- headers={"Referer": BASE_URL, "Origin": BASE_URL},
106
+ headers={**APP_HEADERS, "Referer": BASE_URL, "Origin": BASE_URL},
100
107
  cookies={SESSION_COOKIE_NAME: token},
101
108
  ) as session:
102
109
  response = session.get(f"{BASE_URL}/api/user")
@@ -110,77 +117,45 @@ def get_user_info(token: str) -> UserInfo | None:
110
117
 
111
118
  def _initialize_session() -> tuple[Session, str]:
112
119
  """Initialize session and obtain CSRF token."""
113
-
114
- session = Session(impersonate="chrome", headers={"Referer": BASE_URL, "Origin": BASE_URL})
115
-
116
120
  with console.status("[bold green]Initializing secure connection...", spinner="dots"):
117
- session.get(BASE_URL)
118
- csrf_data = loads(session.get(f"{BASE_URL}/api/auth/csrf").content)
119
- csrf = csrf_data.get("csrfToken")
120
-
121
- if not csrf:
122
- raise ValueError("Failed to obtain CSRF token.")
123
-
124
- return session, csrf
121
+ return create_auth_session()
125
122
 
126
123
 
127
124
  def _request_verification_code(session: Session, csrf: str, email: str) -> None:
128
125
  """Send verification code to user's email."""
129
-
130
126
  with console.status("[bold green]Sending verification code...", spinner="dots"):
131
- response = session.post(
132
- f"{BASE_URL}/api/auth/signin/email?version=2.18&source=default",
133
- json={
134
- "email": email,
135
- "csrfToken": csrf,
136
- "useNumericOtp": "true",
137
- "json": "true",
138
- "callbackUrl": f"{BASE_URL}/?login-source=floatingSignup",
139
- },
140
- )
141
-
142
- if response.status_code != 200:
143
- raise ValueError(f"Authentication request failed: {response.text}")
127
+ request_verification_code(session, csrf, email)
144
128
 
145
129
 
146
130
  def _validate_and_get_redirect_url(session: Session, email: str, user_input: str) -> str:
147
131
  """Validate user input (OTP or magic link) and return redirect URL."""
148
-
149
132
  with console.status("[bold green]Validating...", spinner="dots"):
150
- if user_input.startswith("http"):
151
- return user_input
152
-
153
- response_otp = session.post(
154
- f"{BASE_URL}/api/auth/otp-redirect-link",
155
- json={
156
- "email": email,
157
- "otp": user_input,
158
- "redirectUrl": f"{BASE_URL}/?login-source=floatingSignup",
159
- "emailLoginMethod": "web-otp",
160
- },
161
- )
162
-
163
- if response_otp.status_code != 200:
164
- raise ValueError("Invalid verification code.")
165
-
166
- redirect_path = loads(response_otp.content).get("redirect")
167
-
168
- if not redirect_path:
169
- raise ValueError("No redirect URL received.")
133
+ return resolve_redirect_url(session, email, user_input)
170
134
 
171
- return f"{BASE_URL}{redirect_path}" if redirect_path.startswith("/") else redirect_path
172
135
 
136
+ def _complete_auth_callback(session: Session, redirect_url: str, totp_code: str | None = None) -> str:
137
+ """Complete the callback and optional TOTP challenge, then return the session token."""
138
+ challenge_token = follow_auth_callback(session, redirect_url)
139
+ if challenge_token:
140
+ if not totp_code:
141
+ raise ValueError("TOTP is required. Re-run with --totp-code and the code from your authenticator app.")
142
+ verify_totp(session, challenge_token, totp_code)
143
+ return extract_session_token(session)
173
144
 
174
- def _extract_session_token(session: Session, redirect_url: str) -> str:
175
- """Extract session token from cookies after authentication."""
176
145
 
177
- session.get(redirect_url)
178
- token = session.cookies.get(SESSION_COOKIE_NAME)
146
+ def _prompt_and_verify_totp(session: Session, challenge_token: str) -> None:
147
+ """Prompt until a valid TOTP code completes the challenge."""
148
+ console.print("\n[bold cyan]Step 3: Two-Factor Authentication[/bold cyan]")
149
+ console.print(" Enter the 6-digit code from your authenticator app.")
179
150
 
180
- if not token:
181
- raise ValueError("Authentication successful, but token not found.")
182
-
183
- return token
151
+ while True:
152
+ totp_code = Prompt.ask(" Enter TOTP code", console=console).strip()
153
+ try:
154
+ with console.status("[bold green]Verifying TOTP...", spinner="dots"):
155
+ verify_totp(session, challenge_token, totp_code)
156
+ return
157
+ except ValueError as error:
158
+ console.print(f"[red] {error}[/red]")
184
159
 
185
160
 
186
161
  def _display_user_info(user_info: UserInfo) -> None:
@@ -245,13 +220,19 @@ def _show_exit_message() -> None:
245
220
  console.input()
246
221
 
247
222
 
248
- def auth_non_interactive(email: str, code: str | None = None, auto_save: bool = True) -> str | None:
223
+ def auth_non_interactive(
224
+ email: str,
225
+ code: str | None = None,
226
+ auto_save: bool = True,
227
+ totp_code: str | None = None,
228
+ ) -> str | None:
249
229
  """Non-interactive authentication for AI agents.
250
230
 
251
231
  Args:
252
232
  email: Perplexity account email
253
233
  code: 6-digit verification code (if None, sends code and returns None)
254
234
  auto_save: Whether to automatically save token to config
235
+ totp_code: Optional 6-digit authenticator code for accounts with TOTP enabled
255
236
 
256
237
  Returns:
257
238
  Session token if code provided, None if code was sent
@@ -275,7 +256,7 @@ def auth_non_interactive(email: str, code: str | None = None, auto_save: bool =
275
256
 
276
257
  # Step 2: Complete authentication
277
258
  redirect_url = _validate_and_get_redirect_url(session, email, code)
278
- token = _extract_session_token(session, redirect_url)
259
+ token = _complete_auth_callback(session, redirect_url, totp_code)
279
260
 
280
261
  # Verify token works
281
262
  user_info = get_user_info(token)
@@ -318,6 +299,7 @@ def main() -> NoReturn:
318
299
  " --check Check if authenticated without logging in\n"
319
300
  " --email EMAIL Email address for non-interactive auth\n"
320
301
  " --code CODE 6-digit verification code from email\n"
302
+ " --totp-code CODE 6-digit code from your authenticator app\n"
321
303
  " --no-save Don't save token to config (non-interactive only)\n"
322
304
  " -h, --help Show this help message\n\n"
323
305
  "[bold cyan]Token Storage:[/bold cyan]\n"
@@ -364,13 +346,18 @@ def main() -> NoReturn:
364
346
  code_idx = args.index("--code")
365
347
  code = args[code_idx + 1] if code_idx + 1 < len(args) else None
366
348
 
349
+ totp_code = None
350
+ if "--totp-code" in args:
351
+ totp_idx = args.index("--totp-code")
352
+ totp_code = args[totp_idx + 1] if totp_idx + 1 < len(args) else None
353
+
367
354
  no_save = "--no-save" in args
368
355
 
369
356
  if not email:
370
357
  print("Error: --email requires an email address")
371
358
  exit(1)
372
359
 
373
- result = auth_non_interactive(email, code, auto_save=not no_save)
360
+ result = auth_non_interactive(email, code, auto_save=not no_save, totp_code=totp_code)
374
361
  exit(0 if result or code is None else 1)
375
362
 
376
363
  # Interactive mode (original behavior)
@@ -387,8 +374,10 @@ def main() -> NoReturn:
387
374
  console.print(" Check your email for a [bold]6-digit code[/bold] or [bold]magic link[/bold].")
388
375
  user_input = Prompt.ask(" Enter code or paste link", console=console).strip()
389
376
  redirect_url = _validate_and_get_redirect_url(session, email, user_input)
390
-
391
- token = _extract_session_token(session, redirect_url)
377
+ challenge_token = follow_auth_callback(session, redirect_url)
378
+ if challenge_token:
379
+ _prompt_and_verify_totp(session, challenge_token)
380
+ token = extract_session_token(session)
392
381
 
393
382
  _display_and_save_token(token)
394
383
 
@@ -22,7 +22,12 @@ from rich.prompt import Confirm, Prompt
22
22
  from rich.syntax import Syntax
23
23
  from rich.table import Table
24
24
  import rich_click as click
25
- import tomllib
25
+
26
+
27
+ try:
28
+ import tomllib
29
+ except ModuleNotFoundError: # Python 3.10
30
+ import tomli as tomllib
26
31
 
27
32
 
28
33
  console = Console()
@@ -9,6 +9,12 @@ from typing import Final
9
9
  API_VERSION: Final[str] = "2.18"
10
10
  """Current API version used by Perplexity WebUI."""
11
11
 
12
+ APP_HEADERS: Final[dict[str, str]] = {
13
+ "x-app-apiclient": "default",
14
+ "x-app-apiversion": API_VERSION,
15
+ }
16
+ """Headers used by the Perplexity web application for internal API requests."""
17
+
12
18
  API_BASE_URL: Final[str] = "https://www.perplexity.ai"
13
19
  """Base URL for all API requests."""
14
20
 
@@ -52,6 +58,7 @@ JSON_OBJECT_PATTERN: Final[Pattern[str]] = compile(r"^\{.*\}$")
52
58
  """Pattern to detect JSON object strings."""
53
59
 
54
60
  DEFAULT_HEADERS: Final[dict[str, str]] = {
61
+ **APP_HEADERS,
55
62
  "Accept": "text/event-stream, application/json",
56
63
  "Content-Type": "application/json",
57
64
  }
@@ -590,10 +590,12 @@ class Conversation:
590
590
  except KeyError as error:
591
591
  raise ResponseParsingError("Missing 'text' field in data", raw_data=str(data)) from error
592
592
  except JSONDecodeError as error:
593
- print("ERROR DATA:", data.get("text"))
594
- raise ResponseParsingError(
595
- "Invalid JSON in 'text' field", raw_data=str(data.get("text", ""))[:500]
596
- ) from error
593
+ raw_text = data.get("text", "")
594
+ logger.warning(f"Non-JSON text in SSE data: {raw_text[:200]}")
595
+ stripped = raw_text.lstrip()
596
+ if stripped and not stripped.startswith(("{", "[")):
597
+ raise ResponseParsingError(raw_text) from error
598
+ raise ResponseParsingError("Invalid JSON in 'text' field", raw_data=str(raw_text)[:500]) from error
597
599
 
598
600
  answer_data: dict[str, Any] = {}
599
601
 
@@ -2,7 +2,7 @@
2
2
  name: perplexity-web-mcp
3
3
  description: 'Search the web and query AI models via Perplexity AI using perplexity-web-mcp-cli. Supports CLI commands (pwm ask, pwm research), MCP tools (pplx_*), and Anthropic/OpenAI-compatible API server. Use when the user mentions "perplexity", "pplx", "pwm", "web search with AI", "deep research", "search the internet", or wants to query premium models like GPT-5.6 Terra, GPT-5.6 Sol, Grok, Claude, Gemini, GLM, or Nemotron through Perplexity''s web interface.'
4
4
  metadata:
5
- version: "0.14.2"
5
+ version: "0.14.4"
6
6
  author: "Jacob BD"
7
7
  ---
8
8
 
@@ -209,8 +209,8 @@ User wants to...
209
209
  |
210
210
  +-- Authenticate / re-authenticate
211
211
  | +-- Interactive: pwm login
212
- | +-- Non-interactive: pwm login --email EMAIL then pwm login --email EMAIL --code CODE
213
- | +-- MCP (no shell): pplx_auth_request_code(email) then pplx_auth_complete(email, code)
212
+ | +-- Non-interactive: pwm login --email EMAIL, then pwm login --email EMAIL --code CODE [--totp-code CODE]
213
+ | +-- MCP (no shell): pplx_auth_request_code(email), then pplx_auth_complete(email, code[, totp_code])
214
214
  |
215
215
  +-- Start MCP server
216
216
  | +-- pwm-mcp
@@ -326,8 +326,11 @@ pwm login # Interactive
326
326
  pwm login --check # Check status
327
327
  pwm login --email user@example.com # Send code
328
328
  pwm login --email user@example.com --code 123456 # Complete
329
+ pwm login --email user@example.com --code 123456 --totp-code 654321 # Complete with TOTP
329
330
  ```
330
331
 
332
+ Set `PWM_SAVE_TO_LIBRARY=1` to save shared CLI and MCP queries to the Perplexity thread library. Queries are incognito by default.
333
+
331
334
  ### Usage
332
335
 
333
336
  ```bash
@@ -360,7 +363,7 @@ pwm usage --refresh # Force-refresh from server
360
363
  | `pplx_connectors` | FREE | List account connector source IDs for `source_focus` |
361
364
  | `pplx_auth_status` | FREE | Check auth status |
362
365
  | `pplx_auth_request_code` | FREE | Send verification code |
363
- | `pplx_auth_complete` | FREE | Complete auth with code |
366
+ | `pplx_auth_complete` | FREE | Complete email and optional TOTP authentication |
364
367
 
365
368
  All query tools accept `source_focus`: `"none"`, `"web"`, `"academic"`, `"social"`, `"finance"`, `"all"`, or a connector source ID from `pplx_connectors()`.
366
369
  Use `source_focus="none"` for model-only queries without web search.
@@ -513,6 +516,7 @@ pwm login --check && pwm usage
513
516
  pwm login --email user@example.com
514
517
  # wait for email, then:
515
518
  pwm login --email user@example.com --code 123456
519
+ # For a TOTP-enabled account, include: --totp-code 654321
516
520
  ```
517
521
 
518
522
  ## API Server
@@ -255,10 +255,13 @@ Complete authentication with the verification code. Second step of re-authentica
255
255
  ```
256
256
  pplx_auth_complete(
257
257
  email: str, # Required. Same email used in pplx_auth_request_code.
258
- code: str, # Required. 6-digit code from email.
258
+ code: str = "", # 6-digit code from email.
259
+ totp_code: str | None = None, # Authenticator code when TOTP is enabled.
259
260
  ) -> str
260
261
  ```
261
262
 
263
+ If the first completion call returns `TOTP_REQUIRED`, call the tool again with `email` and `totp_code`.
264
+
262
265
  ## Response Format
263
266
 
264
267
  All query tools return a string containing:
@@ -13,6 +13,14 @@ import uuid
13
13
 
14
14
  from fastmcp import FastMCP
15
15
 
16
+ from perplexity_web_mcp.auth import (
17
+ create_auth_session,
18
+ extract_session_token,
19
+ follow_auth_callback,
20
+ request_verification_code,
21
+ resolve_redirect_url,
22
+ verify_totp,
23
+ )
16
24
  from perplexity_web_mcp.models import Models
17
25
  from perplexity_web_mcp.shared import (
18
26
  COUNCIL_DEFAULT_MODELS_STR,
@@ -74,7 +82,7 @@ mcp = FastMCP(
74
82
  "AUTHENTICATION: If you get a 403 error or 'token expired' message:\n"
75
83
  "1. pplx_auth_status — check current authentication status\n"
76
84
  "2. pplx_auth_request_code — send verification code to email\n"
77
- "3. pplx_auth_complete — complete auth with the 6-digit code"
85
+ "3. pplx_auth_complete — complete email OTP and any requested TOTP challenge"
78
86
  ),
79
87
  )
80
88
 
@@ -683,33 +691,9 @@ def pplx_auth_request_code(email: str) -> str:
683
691
  Returns:
684
692
  Status message indicating if the code was sent successfully
685
693
  """
686
- from curl_cffi.requests import Session
687
- from orjson import loads
688
-
689
- BASE_URL = "https://www.perplexity.ai"
690
-
691
694
  try:
692
- session = Session(impersonate="chrome", headers={"Referer": BASE_URL, "Origin": BASE_URL})
693
- session.get(BASE_URL)
694
- csrf_data = loads(session.get(f"{BASE_URL}/api/auth/csrf").content)
695
- csrf = csrf_data.get("csrfToken")
696
-
697
- if not csrf:
698
- return "ERROR: Failed to obtain CSRF token. Please try again."
699
-
700
- response = session.post(
701
- f"{BASE_URL}/api/auth/signin/email?version=2.18&source=default",
702
- json={
703
- "email": email,
704
- "csrfToken": csrf,
705
- "useNumericOtp": "true",
706
- "json": "true",
707
- "callbackUrl": f"{BASE_URL}/?login-source=floatingSignup",
708
- },
709
- )
710
-
711
- if response.status_code != 200:
712
- return f"ERROR: Failed to send verification code. Status: {response.status_code}"
695
+ session, csrf = create_auth_session()
696
+ request_verification_code(session, csrf, email)
713
697
 
714
698
  _set_auth_session({"session": session, "email": email})
715
699
 
@@ -725,7 +709,7 @@ def pplx_auth_request_code(email: str) -> str:
725
709
 
726
710
 
727
711
  @mcp.tool
728
- def pplx_auth_complete(email: str, code: str) -> str:
712
+ def pplx_auth_complete(email: str, code: str = "", totp_code: str | None = None) -> str:
729
713
  """Complete Perplexity authentication with the verification code.
730
714
 
731
715
  Use the 6-digit code received via email after calling pplx_auth_request_code.
@@ -734,50 +718,37 @@ def pplx_auth_complete(email: str, code: str) -> str:
734
718
  Args:
735
719
  email: Your Perplexity account email (same as used in pplx_auth_request_code)
736
720
  code: The 6-digit verification code from your email
721
+ totp_code: The 6-digit authenticator code when TOTP is enabled
737
722
 
738
723
  Returns:
739
724
  Status message with authentication result and subscription tier
740
725
  """
741
- from curl_cffi.requests import Session
742
- from orjson import loads
743
-
744
726
  from perplexity_web_mcp.cli.auth import get_user_info
745
727
 
746
- BASE_URL = "https://www.perplexity.ai"
747
- SESSION_COOKIE_NAME = "__Secure-next-auth.session-token"
748
-
749
728
  try:
750
729
  stored = _get_auth_session(email)
751
730
  if stored:
752
731
  session = stored["session"]
753
732
  else:
754
- session = Session(impersonate="chrome", headers={"Referer": BASE_URL, "Origin": BASE_URL})
755
- session.get(BASE_URL)
756
-
757
- response = session.post(
758
- f"{BASE_URL}/api/auth/otp-redirect-link",
759
- json={
760
- "email": email,
761
- "otp": code,
762
- "redirectUrl": f"{BASE_URL}/?login-source=floatingSignup",
763
- "emailLoginMethod": "web-otp",
764
- },
765
- )
766
-
767
- if response.status_code != 200:
768
- return "ERROR: Invalid verification code. Please check and try again."
769
-
770
- redirect_path = loads(response.content).get("redirect")
771
- if not redirect_path:
772
- return "ERROR: No redirect URL received. Please try again."
733
+ session, _ = create_auth_session()
773
734
 
774
- redirect_url = f"{BASE_URL}{redirect_path}" if redirect_path.startswith("/") else redirect_path
775
-
776
- session.get(redirect_url)
777
- token = session.cookies.get(SESSION_COOKIE_NAME)
778
-
779
- if not token:
780
- return "ERROR: Authentication succeeded but token not found."
735
+ challenge_token = stored.get("challenge_token") if stored else None
736
+ if challenge_token:
737
+ if not totp_code:
738
+ return "TOTP_REQUIRED: Call pplx_auth_complete again with email and totp_code."
739
+ verify_totp(session, challenge_token, totp_code)
740
+ else:
741
+ if not code:
742
+ return "ERROR: The 6-digit email verification code is required."
743
+ redirect_url = resolve_redirect_url(session, email, code)
744
+ challenge_token = follow_auth_callback(session, redirect_url)
745
+ if challenge_token:
746
+ _set_auth_session({"session": session, "email": email, "challenge_token": challenge_token})
747
+ if not totp_code:
748
+ return "TOTP_REQUIRED: Call pplx_auth_complete again with email and totp_code."
749
+ verify_totp(session, challenge_token, totp_code)
750
+
751
+ token = extract_session_token(session)
781
752
 
782
753
  if save_token(token):
783
754
  _clear_auth_session()
@@ -17,7 +17,14 @@ from typing import Any
17
17
 
18
18
  from curl_cffi.requests import Session
19
19
 
20
- from .constants import API_BASE_URL, ENDPOINT_CREDITS, ENDPOINT_RATE_LIMITS, ENDPOINT_USER_SETTINGS, SESSION_COOKIE_NAME
20
+ from .constants import (
21
+ API_BASE_URL,
22
+ APP_HEADERS,
23
+ ENDPOINT_CREDITS,
24
+ ENDPOINT_RATE_LIMITS,
25
+ ENDPOINT_USER_SETTINGS,
26
+ SESSION_COOKIE_NAME,
27
+ )
21
28
  from .logging import get_logger
22
29
 
23
30
 
@@ -304,7 +311,12 @@ def _create_session(token: str) -> Session:
304
311
  """Create a minimal session for REST API calls."""
305
312
  return Session(
306
313
  impersonate="chrome",
307
- headers={"Referer": API_BASE_URL, "Origin": API_BASE_URL},
314
+ headers={
315
+ **APP_HEADERS,
316
+ "Referer": API_BASE_URL,
317
+ "Origin": API_BASE_URL,
318
+ "Accept": "application/json",
319
+ },
308
320
  cookies={SESSION_COOKIE_NAME: token},
309
321
  )
310
322
 
@@ -278,6 +278,11 @@ def _shared_client_config_from_env() -> ClientConfig:
278
278
  )
279
279
 
280
280
 
281
+ def _save_to_library_from_env() -> bool:
282
+ """Return whether shared CLI/MCP queries should be saved to the library."""
283
+ return environ.get("PWM_SAVE_TO_LIBRARY", "").strip().lower() in {"1", "true", "yes", "on"}
284
+
285
+
281
286
  def get_client() -> Perplexity:
282
287
  """Get or create a cached Perplexity client.
283
288
 
@@ -390,6 +395,7 @@ def _execute_query(
390
395
  citation_mode=CitationMode.DEFAULT,
391
396
  search_focus=search_focus,
392
397
  source_focus=sources,
398
+ save_to_library=_save_to_library_from_env(),
393
399
  )
394
400
  )
395
401