perplexity-user-mcp 0.8.36

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 (125) hide show
  1. package/README.md +192 -0
  2. package/dist/attachments.d.ts +20 -0
  3. package/dist/attachments.mjs +43 -0
  4. package/dist/checks/browser.d.ts +100 -0
  5. package/dist/checks/browser.mjs +89 -0
  6. package/dist/checks/config.d.ts +91 -0
  7. package/dist/checks/config.mjs +88 -0
  8. package/dist/checks/ide.d.ts +89 -0
  9. package/dist/checks/ide.mjs +80 -0
  10. package/dist/checks/mcp.d.ts +61 -0
  11. package/dist/checks/mcp.mjs +56 -0
  12. package/dist/checks/native-deps.d.ts +131 -0
  13. package/dist/checks/native-deps.mjs +115 -0
  14. package/dist/checks/network.d.ts +71 -0
  15. package/dist/checks/network.mjs +70 -0
  16. package/dist/checks/probe.d.ts +93 -0
  17. package/dist/checks/probe.mjs +82 -0
  18. package/dist/checks/profiles.d.ts +99 -0
  19. package/dist/checks/profiles.mjs +90 -0
  20. package/dist/checks/runtime.d.ts +89 -0
  21. package/dist/checks/runtime.mjs +90 -0
  22. package/dist/checks/vault.d.ts +101 -0
  23. package/dist/checks/vault.mjs +90 -0
  24. package/dist/chunk-3B276PGG.mjs +115 -0
  25. package/dist/chunk-4UEJOM6W.mjs +9 -0
  26. package/dist/chunk-6EP2BLTV.mjs +205 -0
  27. package/dist/chunk-6YMQVLFX.mjs +146 -0
  28. package/dist/chunk-7JL36EBH.mjs +118 -0
  29. package/dist/chunk-DPGMKSSA.mjs +57 -0
  30. package/dist/chunk-H4BUAPPO.mjs +1950 -0
  31. package/dist/chunk-HMKLWVXB.mjs +109 -0
  32. package/dist/chunk-HTUAQRKH.mjs +125 -0
  33. package/dist/chunk-HU5B4FXS.mjs +139 -0
  34. package/dist/chunk-KCXM2M4B.mjs +1006 -0
  35. package/dist/chunk-LKJMLGFP.mjs +237 -0
  36. package/dist/chunk-LZPLNZ5U.mjs +67 -0
  37. package/dist/chunk-MTDFKNXX.mjs +19 -0
  38. package/dist/chunk-OF4DMAPJ.mjs +511 -0
  39. package/dist/chunk-PE23RMXY.mjs +43 -0
  40. package/dist/chunk-Q2VY4R5F.mjs +175 -0
  41. package/dist/chunk-S5VD7WTU.mjs +2540 -0
  42. package/dist/chunk-SVPRB62V.mjs +106 -0
  43. package/dist/chunk-TQLCLE4L.mjs +345 -0
  44. package/dist/chunk-U3DGFLXZ.mjs +43 -0
  45. package/dist/chunk-X45O6YD3.mjs +688 -0
  46. package/dist/chunk-XKSWCEGI.mjs +168 -0
  47. package/dist/chunk-Z7DAACGZ.mjs +534 -0
  48. package/dist/chunk-ZQFUZPLO.mjs +257 -0
  49. package/dist/cli.d.ts +952 -0
  50. package/dist/cli.mjs +827 -0
  51. package/dist/client.d.ts +355 -0
  52. package/dist/client.mjs +27 -0
  53. package/dist/cloud-sync.d-Cqt6y18U.d.ts +42 -0
  54. package/dist/cloud-sync.d.ts +42 -0
  55. package/dist/cloud-sync.mjs +17 -0
  56. package/dist/config.d.ts +186 -0
  57. package/dist/config.mjs +54 -0
  58. package/dist/daemon/attach.d.ts +36 -0
  59. package/dist/daemon/attach.mjs +25 -0
  60. package/dist/daemon/audit.d.ts +23 -0
  61. package/dist/daemon/audit.mjs +12 -0
  62. package/dist/daemon/client-http.d.ts +42 -0
  63. package/dist/daemon/client-http.mjs +29 -0
  64. package/dist/daemon/index.d.ts +14 -0
  65. package/dist/daemon/index.mjs +110 -0
  66. package/dist/daemon/install-tunnel.d.ts +46 -0
  67. package/dist/daemon/install-tunnel.mjs +14 -0
  68. package/dist/daemon/launcher.d.ts +163 -0
  69. package/dist/daemon/launcher.mjs +50 -0
  70. package/dist/daemon/lockfile.d.ts +29 -0
  71. package/dist/daemon/lockfile.mjs +18 -0
  72. package/dist/daemon/server.d.ts +159 -0
  73. package/dist/daemon/server.mjs +20 -0
  74. package/dist/daemon/token.d.ts +17 -0
  75. package/dist/daemon/token.mjs +17 -0
  76. package/dist/daemon/tunnel-providers/index.d.ts +330 -0
  77. package/dist/daemon/tunnel-providers/index.mjs +57 -0
  78. package/dist/daemon/tunnel.d.ts +23 -0
  79. package/dist/daemon/tunnel.mjs +9 -0
  80. package/dist/doctor-report.d.ts +24 -0
  81. package/dist/doctor-report.mjs +14 -0
  82. package/dist/doctor.d-CXmUqOXX.d.ts +43 -0
  83. package/dist/doctor.d.ts +44 -0
  84. package/dist/doctor.mjs +16 -0
  85. package/dist/export.d.ts +19 -0
  86. package/dist/export.mjs +15 -0
  87. package/dist/health-check.d.ts +108 -0
  88. package/dist/health-check.mjs +92 -0
  89. package/dist/history-store.d-BzjBF2m3.d.ts +65 -0
  90. package/dist/history-store.d.ts +65 -0
  91. package/dist/history-store.mjs +48 -0
  92. package/dist/impit-login-runner.d.ts +469 -0
  93. package/dist/impit-login-runner.mjs +685 -0
  94. package/dist/index.d.ts +159 -0
  95. package/dist/index.mjs +236 -0
  96. package/dist/login-runner.d.ts +333 -0
  97. package/dist/login-runner.mjs +320 -0
  98. package/dist/logout.d.ts +28 -0
  99. package/dist/logout.mjs +45 -0
  100. package/dist/manual-login-runner.d.ts +150 -0
  101. package/dist/manual-login-runner.mjs +146 -0
  102. package/dist/native-deps-BNThFHxa.d.ts +175 -0
  103. package/dist/native-deps-YNKXITRY.mjs +139 -0
  104. package/dist/profiles.d-DqS1oZWr.d.ts +41 -0
  105. package/dist/profiles.d.ts +41 -0
  106. package/dist/profiles.mjs +33 -0
  107. package/dist/redact.d.ts +159 -0
  108. package/dist/redact.mjs +11 -0
  109. package/dist/refresh.d.ts +118 -0
  110. package/dist/refresh.mjs +21 -0
  111. package/dist/reinit-watcher.d.ts +15 -0
  112. package/dist/reinit-watcher.mjs +8 -0
  113. package/dist/session-metadata-B9aV_n5g.d.ts +148 -0
  114. package/dist/tty-prompt.d.ts +44 -0
  115. package/dist/tty-prompt.mjs +39 -0
  116. package/dist/vault.d-BtRSLZiM.d.ts +8 -0
  117. package/dist/vault.d.ts +37 -0
  118. package/dist/vault.mjs +21 -0
  119. package/dist/viewer-detect.d-HWGnyFAA.d.ts +4 -0
  120. package/dist/viewer-detect.d.ts +4 -0
  121. package/dist/viewer-detect.mjs +37 -0
  122. package/dist/viewers.d-BGCK6sw6.d.ts +10 -0
  123. package/dist/viewers.d.ts +18 -0
  124. package/dist/viewers.mjs +122 -0
  125. package/package.json +152 -0
@@ -0,0 +1,2540 @@
1
+ import {
2
+ appendAuditEntry,
3
+ getAuditLogPath,
4
+ readAuditTail
5
+ } from "./chunk-PE23RMXY.mjs";
6
+ import {
7
+ ensureToken,
8
+ getTokenPath,
9
+ rotateToken
10
+ } from "./chunk-HTUAQRKH.mjs";
11
+ import {
12
+ hydrateCloudHistoryEntry,
13
+ syncCloudHistory
14
+ } from "./chunk-Q2VY4R5F.mjs";
15
+ import {
16
+ PerplexityClient,
17
+ exportThreadViaImpit,
18
+ readCachedAccountInfoFromDisk,
19
+ retrieveThreadViaImpit
20
+ } from "./chunk-H4BUAPPO.mjs";
21
+ import {
22
+ append,
23
+ findPendingByThread,
24
+ get,
25
+ getAttachmentsDir,
26
+ getHistoryDir,
27
+ list,
28
+ update
29
+ } from "./chunk-OF4DMAPJ.mjs";
30
+ import {
31
+ safeAtomicWriteFileSync
32
+ } from "./chunk-MTDFKNXX.mjs";
33
+ import {
34
+ getConfigDir
35
+ } from "./chunk-XKSWCEGI.mjs";
36
+
37
+ // src/daemon/server.ts
38
+ import { createServer } from "http";
39
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
40
+ import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
41
+ import helmet from "helmet";
42
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
43
+ import express from "express";
44
+
45
+ // src/prompts.ts
46
+ import { z } from "zod";
47
+ function registerPrompts(server) {
48
+ server.registerPrompt(
49
+ "perplexity.researchPlan",
50
+ {
51
+ title: "Perplexity Research Plan",
52
+ description: "Generate a prompt that routes to the deep research tool.",
53
+ argsSchema: {
54
+ topic: z.string()
55
+ }
56
+ },
57
+ ({ topic }) => ({
58
+ description: `Deep research prompt for ${topic}`,
59
+ messages: [
60
+ {
61
+ role: "user",
62
+ content: {
63
+ type: "text",
64
+ text: `Use perplexity_research to produce a sourced research brief about "${topic}". Emphasize citations, key findings, and next questions.`
65
+ }
66
+ }
67
+ ]
68
+ })
69
+ );
70
+ server.registerPrompt(
71
+ "perplexity.reasoningPlan",
72
+ {
73
+ title: "Perplexity Reasoning Plan",
74
+ description: "Generate a prompt that routes to the reasoning tool.",
75
+ argsSchema: {
76
+ question: z.string()
77
+ }
78
+ },
79
+ ({ question }) => ({
80
+ description: `Reasoning prompt for ${question}`,
81
+ messages: [
82
+ {
83
+ role: "user",
84
+ content: {
85
+ type: "text",
86
+ text: `Use perplexity_reason on the following question and provide the reasoning trace plus a concise final answer: ${question}`
87
+ }
88
+ }
89
+ ]
90
+ })
91
+ );
92
+ }
93
+
94
+ // src/resources.ts
95
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
96
+ function registerResources(server, getAccountSnapshot) {
97
+ if (getAccountSnapshot) {
98
+ server.registerResource(
99
+ "perplexity.account",
100
+ "perplexity://account/status",
101
+ {
102
+ title: "Perplexity Account Status",
103
+ description: "Cached account metadata loaded from the shared Perplexity browser profile.",
104
+ mimeType: "application/json"
105
+ },
106
+ async (uri) => ({
107
+ contents: [
108
+ {
109
+ uri: uri.toString(),
110
+ mimeType: "application/json",
111
+ text: JSON.stringify(getAccountSnapshot(), null, 2)
112
+ }
113
+ ]
114
+ })
115
+ );
116
+ }
117
+ server.registerResource(
118
+ "perplexity.history",
119
+ "perplexity://history/recent",
120
+ {
121
+ title: "Perplexity Query History",
122
+ description: `Recent tool invocations recorded in ${getHistoryDir()}.`,
123
+ mimeType: "application/json"
124
+ },
125
+ async (uri) => ({
126
+ contents: [
127
+ {
128
+ uri: uri.toString(),
129
+ mimeType: "application/json",
130
+ text: JSON.stringify(list(), null, 2)
131
+ }
132
+ ]
133
+ })
134
+ );
135
+ server.registerResource(
136
+ "perplexity.history.entry",
137
+ new ResourceTemplate("perplexity://history/{id}.md", {
138
+ list: async () => ({
139
+ resources: list({ limit: Infinity }).map((item) => ({
140
+ uri: `perplexity://history/${item.id}.md`,
141
+ name: item.query.slice(0, 80) || item.id,
142
+ description: item.tool,
143
+ mimeType: "text/markdown"
144
+ }))
145
+ })
146
+ }),
147
+ {
148
+ title: "Perplexity History Entry",
149
+ description: "Raw markdown for a single saved history entry.",
150
+ mimeType: "text/markdown"
151
+ },
152
+ async (uri, variables) => {
153
+ const entry = get(String(variables.id ?? ""));
154
+ if (!entry) {
155
+ return { contents: [] };
156
+ }
157
+ return {
158
+ contents: [
159
+ {
160
+ uri: uri.toString(),
161
+ mimeType: "text/markdown",
162
+ text: entry.body
163
+ }
164
+ ]
165
+ };
166
+ }
167
+ );
168
+ }
169
+
170
+ // src/tool-config.ts
171
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, watchFile } from "fs";
172
+ import { join } from "path";
173
+ import { homedir } from "os";
174
+ var CONFIG_DIR = process.env.PERPLEXITY_CONFIG_DIR || join(homedir(), ".perplexity-mcp");
175
+ var TOOL_CONFIG_PATH = join(CONFIG_DIR, "tools-config.json");
176
+ var CATEGORIES = {
177
+ read: [
178
+ "perplexity_search",
179
+ "perplexity_reason",
180
+ "perplexity_research",
181
+ "perplexity_ask",
182
+ "perplexity_models",
183
+ "perplexity_retrieve",
184
+ "perplexity_list_researches",
185
+ "perplexity_get_research",
186
+ "perplexity_doctor"
187
+ ],
188
+ write: [
189
+ "perplexity_compute",
190
+ "perplexity_login",
191
+ "perplexity_export",
192
+ "perplexity_sync_cloud",
193
+ "perplexity_hydrate_cloud_entry"
194
+ ]
195
+ };
196
+ var PROFILES = {
197
+ "read-only": ["read"],
198
+ full: ["read", "write"]
199
+ };
200
+ function loadToolConfig() {
201
+ if (!existsSync(TOOL_CONFIG_PATH)) return { profile: "full" };
202
+ try {
203
+ return JSON.parse(readFileSync(TOOL_CONFIG_PATH, "utf-8"));
204
+ } catch {
205
+ return { profile: "full" };
206
+ }
207
+ }
208
+ function getEnabledTools(config) {
209
+ if (config.profile === "custom" && config.customEnabled) return new Set(config.customEnabled);
210
+ const cats = PROFILES[config.profile] ?? PROFILES.full;
211
+ const enabled = /* @__PURE__ */ new Set();
212
+ for (const cat of cats) for (const tool of CATEGORIES[cat] ?? []) enabled.add(tool);
213
+ return enabled;
214
+ }
215
+ function saveToolConfig(config) {
216
+ mkdirSync(CONFIG_DIR, { recursive: true });
217
+ writeFileSync(TOOL_CONFIG_PATH, JSON.stringify(config, null, 2));
218
+ }
219
+ function watchToolConfig(onChange) {
220
+ if (!existsSync(TOOL_CONFIG_PATH)) return;
221
+ watchFile(TOOL_CONFIG_PATH, { interval: 2e3 }, () => onChange(loadToolConfig()));
222
+ }
223
+
224
+ // src/tools.ts
225
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
226
+ import { dirname, join as join2 } from "path";
227
+ import { z as z2 } from "zod";
228
+
229
+ // src/format.ts
230
+ function getThreadSlug(result) {
231
+ const slug = result?.followUp?.threadUrlSlug ?? null;
232
+ if (slug) return slug;
233
+ if (!result?.threadUrl) return null;
234
+ const match = result.threadUrl.match(/\/search\/([^/?#]+)/);
235
+ return match?.[1] ?? null;
236
+ }
237
+ function formatResponse(result) {
238
+ const parts = [];
239
+ if (result.answer) {
240
+ parts.push(result.answer);
241
+ }
242
+ if (result.reasoning) {
243
+ parts.push(`
244
+
245
+ ---
246
+ **Reasoning:**
247
+ ${result.reasoning}`);
248
+ }
249
+ if (result.sources.length > 0) {
250
+ parts.push("\n\n---\n**Sources:**");
251
+ for (const [index, source] of result.sources.slice(0, 15).entries()) {
252
+ parts.push(`${index + 1}. [${source.title}](${source.url})`);
253
+ }
254
+ }
255
+ if (result.media.length > 0) {
256
+ parts.push("\n\n**Media:**");
257
+ for (const item of result.media.slice(0, 10)) {
258
+ parts.push(`- [${item.name || "Media"}](${item.url})`);
259
+ }
260
+ }
261
+ if (result.files?.length) {
262
+ parts.push("\n\n**Generated Files:**");
263
+ for (const file of result.files) {
264
+ if (file.localPath) {
265
+ parts.push(`- **${file.filename}** (${file.assetType}) -> \`${file.localPath}\``);
266
+ } else if (file.url) {
267
+ parts.push(`- **${file.filename}** (${file.assetType}) -> [Download](${file.url})`);
268
+ }
269
+ }
270
+ }
271
+ if (result.suggestedFollowups.length > 0) {
272
+ parts.push("\n\n**Suggested follow-ups:**");
273
+ for (const followUp of result.suggestedFollowups.slice(0, 5)) {
274
+ parts.push(`- ${followUp}`);
275
+ }
276
+ }
277
+ if (result.threadUrl) {
278
+ parts.push(`
279
+
280
+ **Full thread:** ${result.threadUrl}`);
281
+ }
282
+ return parts.join("\n");
283
+ }
284
+ function buildAnswerPreview(result, error) {
285
+ if (error) {
286
+ return error.slice(0, 220);
287
+ }
288
+ const answer = result?.answer ?? "";
289
+ return answer.replace(/\s+/g, " ").trim().slice(0, 220);
290
+ }
291
+ function buildHistoryEntry(options) {
292
+ return {
293
+ tool: options.tool,
294
+ query: options.query,
295
+ model: options.model,
296
+ mode: options.mode,
297
+ language: options.language,
298
+ answerPreview: buildAnswerPreview(options.result ?? null, options.error),
299
+ sourceCount: options.result?.sources.length ?? 0,
300
+ threadUrl: options.result?.threadUrl,
301
+ error: options.error
302
+ };
303
+ }
304
+ function buildHistoryBody(result, error) {
305
+ if (error && !result) {
306
+ return `# Request failed
307
+
308
+ ${error}`;
309
+ }
310
+ if (!result) {
311
+ return error ? `# Request failed
312
+
313
+ ${error}` : "";
314
+ }
315
+ return formatResponse(result);
316
+ }
317
+ function buildStoredHistoryEntry(options) {
318
+ const base = buildHistoryEntry(options);
319
+ const createdAt = options.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
320
+ const status = options.status ?? (options.error ? "failed" : "completed");
321
+ const sources = (options.result?.sources ?? []).map((source, index) => ({
322
+ n: index + 1,
323
+ title: source.title,
324
+ url: source.url,
325
+ ...source.snippet ? { snippet: source.snippet } : {}
326
+ }));
327
+ return {
328
+ ...base,
329
+ createdAt,
330
+ body: buildHistoryBody(options.result, options.error),
331
+ ...status ? { status } : {},
332
+ ...options.completedAt ? { completedAt: options.completedAt } : status === "completed" ? { completedAt: createdAt } : {},
333
+ ...options.tier ? { tier: options.tier } : {},
334
+ ...getThreadSlug(options.result) !== null ? { threadSlug: getThreadSlug(options.result) } : {},
335
+ ...options.result?.followUp?.backendUuid ? { backendUuid: options.result.followUp.backendUuid } : {},
336
+ ...options.result?.followUp?.readWriteToken !== void 0 ? { readWriteToken: options.result.followUp.readWriteToken } : {},
337
+ ...sources.length > 0 ? { sources } : {}
338
+ };
339
+ }
340
+
341
+ // src/tools.ts
342
+ function success(text) {
343
+ return { content: [{ type: "text", text }] };
344
+ }
345
+ function failure(message) {
346
+ return {
347
+ content: [{ type: "text", text: `Error: ${message}` }],
348
+ isError: true
349
+ };
350
+ }
351
+ function getClientTier(client) {
352
+ return client.accountInfo.isMax ? "Max" : client.accountInfo.isPro ? "Pro" : client.accountInfo.isEnterprise ? "Enterprise" : client.authenticated ? "Authenticated" : "Anonymous";
353
+ }
354
+ function recordToolRun(options) {
355
+ try {
356
+ append(buildStoredHistoryEntry(options));
357
+ } catch {
358
+ }
359
+ }
360
+ function isResearchTool(tool) {
361
+ return tool === "perplexity_compute" || tool === "perplexity_research";
362
+ }
363
+ function buildModelsResponseFromAccountInfo(info, userId, authenticated) {
364
+ const tier = info.isMax ? "Max" : info.isPro ? "Pro" : info.isEnterprise ? "Enterprise" : authenticated ? "Authenticated" : "Anonymous";
365
+ const lines = [
366
+ `**Account tier:** ${tier}`,
367
+ `**User ID:** ${userId || "anonymous"}`,
368
+ `**Computer mode:** ${info.canUseComputer ? "Available" : "Not available"}`
369
+ ];
370
+ if (info.modelsConfig) {
371
+ const groups = {};
372
+ for (const entry of info.modelsConfig.config) {
373
+ const mode = info.modelsConfig.models[entry.non_reasoning_model || entry.reasoning_model || ""]?.mode || "other";
374
+ if (!groups[mode]) {
375
+ groups[mode] = [];
376
+ }
377
+ groups[mode].push(entry);
378
+ }
379
+ for (const [mode, entries] of Object.entries(groups)) {
380
+ lines.push("");
381
+ lines.push(`## ${mode}`);
382
+ for (const entry of entries) {
383
+ const tierBadge = entry.subscription_tier === "max" ? " [MAX]" : entry.subscription_tier === "pro" ? " [PRO]" : "";
384
+ const models = [entry.non_reasoning_model, entry.reasoning_model].filter(Boolean).map((value) => `\`${value}\``).join(", ");
385
+ lines.push(`- **${entry.label}**${tierBadge}: ${models}`);
386
+ lines.push(` ${entry.description}`);
387
+ }
388
+ }
389
+ lines.push("");
390
+ lines.push("## Default Models");
391
+ for (const [mode, modelId] of Object.entries(info.modelsConfig.default_models)) {
392
+ lines.push(`- **${mode}**: \`${modelId}\``);
393
+ }
394
+ }
395
+ if (info.rateLimits) {
396
+ lines.push("");
397
+ lines.push("## Rate Limits");
398
+ for (const [mode, state] of Object.entries(info.rateLimits.modes)) {
399
+ const remaining = state.remaining_detail.kind === "exact" && typeof state.remaining_detail.remaining === "number" ? ` (${state.remaining_detail.remaining} remaining)` : "";
400
+ lines.push(`- **${mode}**: ${state.available ? "available" : "unavailable"}${remaining}`);
401
+ }
402
+ }
403
+ return lines.join("\n");
404
+ }
405
+ function buildModelsResponse(client) {
406
+ return buildModelsResponseFromAccountInfo(client.accountInfo, client.userId, client.authenticated);
407
+ }
408
+ function registerTools(server, getClient, enabledTools, hooks = {}) {
409
+ function registerDaemonTool(name, config, handler) {
410
+ const runWithAudit = async (extra, invoke) => {
411
+ const startedAt = Date.now();
412
+ try {
413
+ const result = await invoke();
414
+ hooks.onToolSettled?.({
415
+ tool: name,
416
+ clientId: getClientId(extra),
417
+ source: getRequestSource(extra),
418
+ durationMs: Date.now() - startedAt,
419
+ ok: !Boolean(result?.isError),
420
+ ...result?.isError ? { error: extractToolError(result) } : {}
421
+ });
422
+ return result;
423
+ } catch (error) {
424
+ hooks.onToolSettled?.({
425
+ tool: name,
426
+ clientId: getClientId(extra),
427
+ source: getRequestSource(extra),
428
+ durationMs: Date.now() - startedAt,
429
+ ok: false,
430
+ error: error instanceof Error ? error.message : String(error)
431
+ });
432
+ throw error;
433
+ }
434
+ };
435
+ if (config.inputSchema) {
436
+ server.registerTool(
437
+ name,
438
+ config,
439
+ async (args, extra) => runWithAudit(extra, () => handler(args, extra))
440
+ );
441
+ return;
442
+ }
443
+ server.registerTool(
444
+ name,
445
+ config,
446
+ async (extra) => runWithAudit(extra, () => handler(extra))
447
+ );
448
+ }
449
+ if (!enabledTools || enabledTools.has("perplexity_search")) {
450
+ registerDaemonTool(
451
+ "perplexity_search",
452
+ {
453
+ title: "Perplexity Search",
454
+ description: "Search the web using Perplexity AI with automatic anonymous or authenticated defaults.",
455
+ inputSchema: {
456
+ query: z2.string().describe("The search query or question to ask."),
457
+ sources: z2.array(z2.enum(["web", "scholar", "social"])).optional(),
458
+ language: z2.string().optional()
459
+ },
460
+ annotations: {
461
+ readOnlyHint: true
462
+ }
463
+ },
464
+ async ({ query, sources, language }) => {
465
+ try {
466
+ const client = await getClient();
467
+ const model = process.env.PERPLEXITY_SEARCH_MODEL || (client.authenticated ? "pplx_pro" : "turbo");
468
+ const mode = client.authenticated ? "copilot" : "concise";
469
+ const result = await client.search({
470
+ query,
471
+ modelPreference: model,
472
+ mode,
473
+ sources: sources ?? ["web"],
474
+ language: language ?? "en-US"
475
+ });
476
+ recordToolRun({
477
+ tool: "perplexity_search",
478
+ query,
479
+ model,
480
+ mode,
481
+ language: language ?? "en-US",
482
+ tier: getClientTier(client),
483
+ result
484
+ });
485
+ return success(formatResponse(result));
486
+ } catch (error) {
487
+ const message = error.message;
488
+ recordToolRun({
489
+ tool: "perplexity_search",
490
+ query,
491
+ model: process.env.PERPLEXITY_SEARCH_MODEL || null,
492
+ mode: null,
493
+ language: language ?? "en-US",
494
+ status: "failed",
495
+ error: message
496
+ });
497
+ return failure(message);
498
+ }
499
+ }
500
+ );
501
+ }
502
+ if (!enabledTools || enabledTools.has("perplexity_reason")) {
503
+ registerDaemonTool(
504
+ "perplexity_reason",
505
+ {
506
+ title: "Perplexity Reason",
507
+ description: "Use a reasoning model for multi-step analysis and explanation.",
508
+ inputSchema: {
509
+ query: z2.string(),
510
+ sources: z2.array(z2.enum(["web", "scholar", "social"])).optional(),
511
+ language: z2.string().optional(),
512
+ model: z2.string().optional()
513
+ },
514
+ annotations: {
515
+ readOnlyHint: true
516
+ }
517
+ },
518
+ async ({ query, sources, language, model }) => {
519
+ const client = await getClient();
520
+ if (!client.authenticated) {
521
+ return failure("perplexity_reason requires an authenticated Pro account.");
522
+ }
523
+ try {
524
+ const resolvedModel = model || process.env.PERPLEXITY_REASON_MODEL || "claude46sonnetthinking";
525
+ const result = await client.search({
526
+ query,
527
+ modelPreference: resolvedModel,
528
+ mode: "copilot",
529
+ sources: sources ?? ["web"],
530
+ language: language ?? "en-US"
531
+ });
532
+ recordToolRun({
533
+ tool: "perplexity_reason",
534
+ query,
535
+ model: resolvedModel,
536
+ mode: "copilot",
537
+ language: language ?? "en-US",
538
+ tier: getClientTier(client),
539
+ result
540
+ });
541
+ return success(formatResponse(result));
542
+ } catch (error) {
543
+ const message = error.message;
544
+ recordToolRun({
545
+ tool: "perplexity_reason",
546
+ query,
547
+ model: model ?? process.env.PERPLEXITY_REASON_MODEL ?? null,
548
+ mode: "copilot",
549
+ language: language ?? "en-US",
550
+ tier: getClientTier(client),
551
+ status: "failed",
552
+ error: message
553
+ });
554
+ return failure(message);
555
+ }
556
+ }
557
+ );
558
+ }
559
+ if (!enabledTools || enabledTools.has("perplexity_research")) {
560
+ registerDaemonTool(
561
+ "perplexity_research",
562
+ {
563
+ title: "Perplexity Research",
564
+ description: "Run a deep research task with the long-form research model.",
565
+ inputSchema: {
566
+ query: z2.string(),
567
+ sources: z2.array(z2.enum(["web", "scholar", "social"])).optional(),
568
+ language: z2.string().optional()
569
+ },
570
+ annotations: {
571
+ readOnlyHint: true
572
+ }
573
+ },
574
+ async ({ query, sources, language }) => {
575
+ const client = await getClient();
576
+ if (!client.authenticated) {
577
+ return failure("perplexity_research requires an authenticated Pro account.");
578
+ }
579
+ try {
580
+ const model = process.env.PERPLEXITY_RESEARCH_MODEL || "pplx_alpha";
581
+ console.error("[perplexity-mcp] Starting deep research...");
582
+ const result = await client.search({
583
+ query,
584
+ modelPreference: model,
585
+ mode: "copilot",
586
+ sources: sources ?? ["web"],
587
+ language: language ?? "en-US"
588
+ });
589
+ console.error("[perplexity-mcp] Research complete.");
590
+ recordToolRun({
591
+ tool: "perplexity_research",
592
+ query,
593
+ model,
594
+ mode: "copilot",
595
+ language: language ?? "en-US",
596
+ tier: getClientTier(client),
597
+ result
598
+ });
599
+ return success(formatResponse(result));
600
+ } catch (error) {
601
+ const message = error.message;
602
+ recordToolRun({
603
+ tool: "perplexity_research",
604
+ query,
605
+ model: process.env.PERPLEXITY_RESEARCH_MODEL ?? "pplx_alpha",
606
+ mode: "copilot",
607
+ language: language ?? "en-US",
608
+ tier: getClientTier(client),
609
+ status: "failed",
610
+ error: message
611
+ });
612
+ return failure(message);
613
+ }
614
+ }
615
+ );
616
+ }
617
+ if (!enabledTools || enabledTools.has("perplexity_ask")) {
618
+ registerDaemonTool(
619
+ "perplexity_ask",
620
+ {
621
+ title: "Perplexity Ask",
622
+ description: "Query Perplexity with explicit control over model, mode, and follow-up context.",
623
+ inputSchema: {
624
+ query: z2.string(),
625
+ model: z2.string().optional(),
626
+ mode: z2.enum(["concise", "copilot"]).optional(),
627
+ sources: z2.array(z2.enum(["web", "scholar", "social"])).optional(),
628
+ language: z2.string().optional(),
629
+ follow_up_context: z2.string().optional()
630
+ },
631
+ annotations: {
632
+ readOnlyHint: true
633
+ }
634
+ },
635
+ async ({ query, model, mode, sources, language, follow_up_context }) => {
636
+ const client = await getClient();
637
+ let followUp;
638
+ if (follow_up_context) {
639
+ try {
640
+ followUp = JSON.parse(follow_up_context);
641
+ } catch {
642
+ return failure("follow_up_context must be valid JSON.");
643
+ }
644
+ }
645
+ try {
646
+ const resolvedModel = model ?? process.env.PERPLEXITY_SEARCH_MODEL ?? "pplx_pro";
647
+ const resolvedMode = mode ?? "copilot";
648
+ const result = await client.search({
649
+ query,
650
+ modelPreference: resolvedModel,
651
+ mode: resolvedMode,
652
+ sources: sources ?? ["web"],
653
+ language: language ?? "en-US",
654
+ followUp
655
+ });
656
+ recordToolRun({
657
+ tool: "perplexity_ask",
658
+ query,
659
+ model: resolvedModel,
660
+ mode: resolvedMode,
661
+ language: language ?? "en-US",
662
+ tier: getClientTier(client),
663
+ result
664
+ });
665
+ let response = formatResponse(result);
666
+ if (result.followUp) {
667
+ response += `
668
+
669
+ ---
670
+ **Follow-up context:**
671
+ \`\`\`json
672
+ ${JSON.stringify(result.followUp, null, 2)}
673
+ \`\`\``;
674
+ }
675
+ return success(response);
676
+ } catch (error) {
677
+ const message = error.message;
678
+ recordToolRun({
679
+ tool: "perplexity_ask",
680
+ query,
681
+ model: model ?? process.env.PERPLEXITY_SEARCH_MODEL ?? null,
682
+ mode: mode ?? "copilot",
683
+ language: language ?? "en-US",
684
+ tier: getClientTier(client),
685
+ status: "failed",
686
+ error: message
687
+ });
688
+ return failure(message);
689
+ }
690
+ }
691
+ );
692
+ }
693
+ if (!enabledTools || enabledTools.has("perplexity_models")) {
694
+ registerDaemonTool(
695
+ "perplexity_models",
696
+ {
697
+ title: "Perplexity Models",
698
+ description: "List available models and current account capabilities.",
699
+ annotations: {
700
+ readOnlyHint: true
701
+ }
702
+ },
703
+ async () => {
704
+ const cached = readCachedAccountInfoFromDisk();
705
+ if (cached?.modelsConfig) {
706
+ return success(buildModelsResponseFromAccountInfo(cached, null, true));
707
+ }
708
+ const client = await getClient();
709
+ return success(buildModelsResponse(client));
710
+ }
711
+ );
712
+ }
713
+ if (!enabledTools || enabledTools.has("perplexity_compute")) {
714
+ registerDaemonTool(
715
+ "perplexity_compute",
716
+ {
717
+ title: "Perplexity Compute",
718
+ description: "Run a task using Perplexity Computer mode (ASI).",
719
+ inputSchema: {
720
+ query: z2.string(),
721
+ model: z2.string().optional(),
722
+ language: z2.string().optional()
723
+ }
724
+ },
725
+ async ({ query, model, language }) => {
726
+ const client = await getClient();
727
+ if (!client.authenticated) {
728
+ return failure("perplexity_compute requires an authenticated account.");
729
+ }
730
+ if (!client.accountInfo.canUseComputer) {
731
+ return failure("Computer mode is not available on this account.");
732
+ }
733
+ try {
734
+ const defaultModel = client.accountInfo.modelsConfig?.default_models?.asi || "pplx_asi";
735
+ const resolvedModel = model || process.env.PERPLEXITY_COMPUTE_MODEL || defaultModel;
736
+ console.error("[perplexity-mcp] Starting ASI compute task...");
737
+ const result = await client.computeASI({
738
+ query,
739
+ modelPreference: resolvedModel,
740
+ language: language ?? "en-US"
741
+ });
742
+ console.error("[perplexity-mcp] Compute task complete.");
743
+ const isTimeout = result.answer.startsWith("ASI task timed out");
744
+ const saved = append(buildStoredHistoryEntry({
745
+ query,
746
+ tool: "perplexity_compute",
747
+ model: resolvedModel,
748
+ mode: "asi",
749
+ language: language ?? "en-US",
750
+ tier: getClientTier(client),
751
+ result,
752
+ status: isTimeout ? "pending" : "completed",
753
+ ...isTimeout ? { error: result.answer } : {}
754
+ }));
755
+ let response = formatResponse(result);
756
+ if (isTimeout) {
757
+ response += `
758
+
759
+ ---
760
+ **Research saved** (id: \`${saved.id}\`). Use \`perplexity_retrieve\` with this id to fetch results once complete.`;
761
+ } else {
762
+ response += `
763
+
764
+ ---
765
+ **Research saved** (id: \`${saved.id}\`).`;
766
+ }
767
+ return success(response);
768
+ } catch (error) {
769
+ const message = error.message;
770
+ recordToolRun({
771
+ tool: "perplexity_compute",
772
+ query,
773
+ model: model ?? process.env.PERPLEXITY_COMPUTE_MODEL ?? null,
774
+ mode: "asi",
775
+ language: language ?? "en-US",
776
+ tier: getClientTier(client),
777
+ status: "failed",
778
+ error: message
779
+ });
780
+ return failure(message);
781
+ }
782
+ }
783
+ );
784
+ }
785
+ if (!enabledTools || enabledTools.has("perplexity_retrieve")) {
786
+ registerDaemonTool(
787
+ "perplexity_retrieve",
788
+ {
789
+ title: "Perplexity Retrieve",
790
+ description: "Retrieve results from a previously timed-out or pending research/compute task.",
791
+ inputSchema: {
792
+ research_id: z2.string().optional().describe("ID of a saved research"),
793
+ thread_slug: z2.string().optional().describe("Perplexity thread slug from the URL")
794
+ }
795
+ },
796
+ async ({ research_id, thread_slug }) => {
797
+ let threadSlug = thread_slug ?? null;
798
+ let backendUuid = null;
799
+ let readWriteToken = null;
800
+ let savedId = null;
801
+ if (research_id) {
802
+ const saved = get(research_id);
803
+ if (!saved) return failure(`Research '${research_id}' not found.`);
804
+ threadSlug = saved.threadSlug ?? null;
805
+ backendUuid = saved.backendUuid ?? null;
806
+ readWriteToken = saved.readWriteToken ?? null;
807
+ savedId = saved.id;
808
+ } else if (thread_slug) {
809
+ const pending = findPendingByThread(thread_slug);
810
+ if (pending) {
811
+ backendUuid = pending.backendUuid ?? null;
812
+ readWriteToken = pending.readWriteToken ?? null;
813
+ savedId = pending.id;
814
+ }
815
+ }
816
+ if (!threadSlug && !backendUuid) {
817
+ return failure("Provide either research_id or thread_slug.");
818
+ }
819
+ try {
820
+ const fast = await retrieveThreadViaImpit({
821
+ threadSlug: threadSlug ?? "",
822
+ backendUuid,
823
+ readWriteToken
824
+ });
825
+ if (fast) {
826
+ if (savedId) {
827
+ const isStillRunning = fast.answer.includes("still running");
828
+ const existing = get(savedId);
829
+ if (existing) {
830
+ update(savedId, buildStoredHistoryEntry({
831
+ tool: existing.tool,
832
+ query: existing.query,
833
+ model: existing.model,
834
+ mode: existing.mode,
835
+ language: existing.language,
836
+ tier: existing.tier,
837
+ createdAt: existing.createdAt,
838
+ status: isStillRunning ? "pending" : "completed",
839
+ completedAt: isStillRunning ? existing.completedAt : (/* @__PURE__ */ new Date()).toISOString(),
840
+ result: fast,
841
+ ...isStillRunning ? { error: fast.answer } : {}
842
+ }));
843
+ }
844
+ }
845
+ return success(formatResponse(fast));
846
+ }
847
+ } catch (err) {
848
+ console.error(`[perplexity-mcp] retrieve impit fast path threw: ${err.message}; falling back to browser.`);
849
+ }
850
+ const client = await getClient();
851
+ try {
852
+ const result = await client.retrieveThread({
853
+ threadSlug,
854
+ backendUuid,
855
+ readWriteToken
856
+ });
857
+ if (savedId) {
858
+ const isStillRunning = result.answer.includes("still running");
859
+ const existing = get(savedId);
860
+ if (existing) {
861
+ update(savedId, buildStoredHistoryEntry({
862
+ tool: existing.tool,
863
+ query: existing.query,
864
+ model: existing.model,
865
+ mode: existing.mode,
866
+ language: existing.language,
867
+ tier: existing.tier,
868
+ createdAt: existing.createdAt,
869
+ status: isStillRunning ? "pending" : "completed",
870
+ completedAt: isStillRunning ? existing.completedAt : (/* @__PURE__ */ new Date()).toISOString(),
871
+ result,
872
+ ...isStillRunning ? { error: result.answer } : {}
873
+ }));
874
+ }
875
+ }
876
+ return success(formatResponse(result));
877
+ } catch (error) {
878
+ return failure(error.message);
879
+ }
880
+ }
881
+ );
882
+ }
883
+ if (!enabledTools || enabledTools.has("perplexity_export")) {
884
+ registerDaemonTool(
885
+ "perplexity_export",
886
+ {
887
+ title: "Perplexity Export",
888
+ description: "Export a saved history entry using Perplexity's native export endpoint when available, with local markdown fallback.",
889
+ inputSchema: {
890
+ history_id: z2.string().describe("Saved history entry id"),
891
+ format: z2.enum(["pdf", "markdown", "docx"]).describe("Export format")
892
+ }
893
+ },
894
+ async ({ history_id, format }) => {
895
+ const entry = get(history_id);
896
+ if (!entry) {
897
+ return failure(`History entry '${history_id}' not found.`);
898
+ }
899
+ const attachmentsDir = getAttachmentsDir(history_id) ?? entry.attachmentsDir;
900
+ mkdirSync2(attachmentsDir, { recursive: true });
901
+ if (format === "markdown") {
902
+ const savedPath2 = join2(attachmentsDir, entry.mdPath.split(/[\\/]/).pop() || `${entry.id}.md`);
903
+ writeFileSync2(savedPath2, readFileSync2(entry.mdPath, "utf8"), "utf8");
904
+ return success(`Saved markdown export to \`${savedPath2}\`.`);
905
+ }
906
+ if (!entry.threadSlug) {
907
+ return failure("This entry cannot be exported natively because it has no Perplexity thread slug.");
908
+ }
909
+ try {
910
+ const fast = await exportThreadViaImpit({ threadSlug: entry.threadSlug, format });
911
+ if (fast) {
912
+ const savedPath2 = join2(attachmentsDir, fast.filename);
913
+ mkdirSync2(dirname(savedPath2), { recursive: true });
914
+ writeFileSync2(savedPath2, fast.buffer);
915
+ return success(`Saved ${format} export to \`${savedPath2}\` (${fast.buffer.length} bytes).`);
916
+ }
917
+ } catch (err) {
918
+ console.error(`[perplexity-mcp] export impit fast path threw: ${err.message}; falling back to browser.`);
919
+ }
920
+ const client = await getClient();
921
+ const exported = await client.exportThread({ threadSlug: entry.threadSlug, format });
922
+ const savedPath = join2(attachmentsDir, exported.filename);
923
+ mkdirSync2(dirname(savedPath), { recursive: true });
924
+ writeFileSync2(savedPath, exported.buffer);
925
+ return success(`Saved ${format} export to \`${savedPath}\` (${exported.buffer.length} bytes).`);
926
+ }
927
+ );
928
+ }
929
+ if (!enabledTools || enabledTools.has("perplexity_sync_cloud")) {
930
+ registerDaemonTool(
931
+ "perplexity_sync_cloud",
932
+ {
933
+ title: "Perplexity Sync Cloud",
934
+ description: "Sync Perplexity cloud history into the local history store using the daemon singleton client.",
935
+ inputSchema: {
936
+ page_size: z2.number().int().positive().optional().describe("Optional page size for cloud thread pagination.")
937
+ }
938
+ },
939
+ async ({ page_size }, extra) => {
940
+ const clientId = getClientId(extra);
941
+ const source = getRequestSource(extra);
942
+ const result = await syncCloudHistory({
943
+ getClient,
944
+ pageSize: page_size,
945
+ onProgress: (progress) => {
946
+ hooks.onToolProgress?.({
947
+ tool: "perplexity_sync_cloud",
948
+ clientId,
949
+ source,
950
+ progress: { ...progress }
951
+ });
952
+ }
953
+ });
954
+ return success(
955
+ `Cloud sync complete: fetched=${result.fetched} inserted=${result.inserted} updated=${result.updated} skipped=${result.skipped}`
956
+ );
957
+ }
958
+ );
959
+ }
960
+ if (!enabledTools || enabledTools.has("perplexity_hydrate_cloud_entry")) {
961
+ registerDaemonTool(
962
+ "perplexity_hydrate_cloud_entry",
963
+ {
964
+ title: "Perplexity Hydrate Cloud Entry",
965
+ description: "Hydrate a single cloud-backed history entry using the daemon singleton client.",
966
+ inputSchema: {
967
+ history_id: z2.string().describe("Cloud-backed history entry id to hydrate.")
968
+ }
969
+ },
970
+ async ({ history_id }) => {
971
+ const result = await hydrateCloudHistoryEntry(history_id, { getClient });
972
+ return success(`Cloud hydrate ${result.action}: ${result.id ?? history_id}`);
973
+ }
974
+ );
975
+ }
976
+ if (!enabledTools || enabledTools.has("perplexity_list_researches")) {
977
+ registerDaemonTool(
978
+ "perplexity_list_researches",
979
+ {
980
+ title: "Perplexity List Researches",
981
+ description: "List saved researches with their status. Pending ones can be retrieved with perplexity_retrieve.",
982
+ inputSchema: {
983
+ status: z2.enum(["completed", "pending", "failed"]).optional(),
984
+ limit: z2.number().optional().describe("Max results (default 20)")
985
+ },
986
+ annotations: {
987
+ readOnlyHint: true
988
+ }
989
+ },
990
+ async ({ status, limit }) => {
991
+ const researches = list({
992
+ status,
993
+ limit: limit ?? 20,
994
+ tools: ["perplexity_compute", "perplexity_research"]
995
+ }).filter((entry) => isResearchTool(entry.tool));
996
+ if (researches.length === 0) {
997
+ return success("No saved researches found.");
998
+ }
999
+ const lines = [`**Saved Researches** (${researches.length}):
1000
+ `];
1001
+ for (const r of researches) {
1002
+ const statusIcon = r.status === "completed" ? "\u2705" : r.status === "pending" ? "\u23F3" : "\u274C";
1003
+ const preview = r.answerPreview || r.error || "(no content)";
1004
+ lines.push(`${statusIcon} **${r.query.slice(0, 80)}**`);
1005
+ lines.push(` ID: \`${r.id}\` | Tool: ${r.tool} | ${r.createdAt}`);
1006
+ lines.push(` ${preview}`);
1007
+ if (r.threadUrl) lines.push(` Thread: ${r.threadUrl}`);
1008
+ lines.push("");
1009
+ }
1010
+ return success(lines.join("\n"));
1011
+ }
1012
+ );
1013
+ }
1014
+ if (!enabledTools || enabledTools.has("perplexity_get_research")) {
1015
+ registerDaemonTool(
1016
+ "perplexity_get_research",
1017
+ {
1018
+ title: "Perplexity Get Research",
1019
+ description: "Get the full content of a saved research by ID, including answer, sources, and files.",
1020
+ inputSchema: {
1021
+ research_id: z2.string().describe("ID of the saved research")
1022
+ },
1023
+ annotations: {
1024
+ readOnlyHint: true
1025
+ }
1026
+ },
1027
+ async ({ research_id }) => {
1028
+ const research = get(research_id);
1029
+ if (!research) return failure(`Research '${research_id}' not found.`);
1030
+ const parts = [
1031
+ `# Research: ${research.query}`,
1032
+ `**Status:** ${research.status || "completed"} | **Tool:** ${research.tool} | **Model:** ${research.model || "default"}`,
1033
+ `**Created:** ${research.createdAt}${research.completedAt ? ` | **Completed:** ${research.completedAt}` : ""}`
1034
+ ];
1035
+ if (research.threadUrl) parts.push(`**Thread:** ${research.threadUrl}`);
1036
+ if (research.body) parts.push("", "---", "", research.body);
1037
+ if (research.sources?.length) {
1038
+ parts.push("", "**Sources:**");
1039
+ for (const [i, s] of research.sources.slice(0, 15).entries()) {
1040
+ parts.push(`${i + 1}. [${s.title}](${s.url})`);
1041
+ }
1042
+ }
1043
+ if (research.attachments?.length) {
1044
+ parts.push("", "**Files:**");
1045
+ for (const file of research.attachments) {
1046
+ parts.push(`- **${file.filename}** -> \`${file.relPath}\``);
1047
+ }
1048
+ }
1049
+ if (research.error) parts.push("", `**Error:** ${research.error}`);
1050
+ if (research.status === "pending") {
1051
+ parts.push("", `---
1052
+ *This research is still pending. Use \`perplexity_retrieve\` with id \`${research.id}\` to fetch updated results.*`);
1053
+ }
1054
+ return success(parts.join("\n"));
1055
+ }
1056
+ );
1057
+ }
1058
+ if (!enabledTools || enabledTools.has("perplexity_login")) {
1059
+ registerDaemonTool(
1060
+ "perplexity_login",
1061
+ {
1062
+ title: "Perplexity Login",
1063
+ description: "Returns instructions for completing Perplexity login. Login is interactive (email + OTP) and must be initiated from the IDE extension dashboard or the CLI \u2014 an MCP tool call cannot prompt for the OTP."
1064
+ },
1065
+ async () => {
1066
+ const message = [
1067
+ "**Perplexity login is interactive \u2014 run it from the dashboard or CLI:**",
1068
+ "",
1069
+ "1. **IDE / Extension:** open the Perplexity dashboard and click *Login*. Enter your email; the OTP prompt appears in the dashboard.",
1070
+ "2. **CLI:** `npx perplexity-user-mcp login --mode auto --email YOUR_EMAIL@example.com` \u2014 the OTP is read from your terminal.",
1071
+ "",
1072
+ "Both paths share the same vault, so once you're logged in via either, all MCP tools (search, reason, research, sync, hydrate, etc.) work immediately. Speed Boost (impit) is used automatically when installed."
1073
+ ].join("\n");
1074
+ return {
1075
+ content: [{ type: "text", text: message }],
1076
+ isError: false
1077
+ };
1078
+ }
1079
+ );
1080
+ }
1081
+ if (!enabledTools || enabledTools.has("perplexity_doctor")) {
1082
+ registerDaemonTool(
1083
+ "perplexity_doctor",
1084
+ {
1085
+ title: "Perplexity Doctor",
1086
+ description: "Run diagnostic checks against your Perplexity MCP install. Returns a Markdown report across ten categories; pass probe:true for a live search probe.",
1087
+ inputSchema: {
1088
+ probe: z2.boolean().optional(),
1089
+ profile: z2.string().optional()
1090
+ },
1091
+ annotations: {
1092
+ readOnlyHint: true
1093
+ }
1094
+ },
1095
+ async ({ probe, profile }) => {
1096
+ const { runAll, formatReportMarkdown } = await import("./doctor.mjs");
1097
+ const report = await runAll({ probe: !!probe, profile });
1098
+ return success(formatReportMarkdown(report));
1099
+ }
1100
+ );
1101
+ }
1102
+ }
1103
+ function getClientId(extra) {
1104
+ return typeof extra?.authInfo?.clientId === "string" && extra.authInfo.clientId.length > 0 ? extra.authInfo.clientId : "daemon-client";
1105
+ }
1106
+ function getRequestSource(extra) {
1107
+ return extra?.authInfo?.extra?.source === "tunnel" ? "tunnel" : "loopback";
1108
+ }
1109
+ function extractToolError(result) {
1110
+ const firstText = result?.content?.find?.((item) => item?.type === "text" && typeof item.text === "string");
1111
+ return typeof firstText?.text === "string" ? firstText.text : "Tool returned an error result.";
1112
+ }
1113
+
1114
+ // src/package-version.ts
1115
+ import { readFileSync as readFileSync3 } from "fs";
1116
+ var cachedPackageVersion = null;
1117
+ function getPackageVersion() {
1118
+ if (cachedPackageVersion) {
1119
+ return cachedPackageVersion;
1120
+ }
1121
+ const moduleUrl = typeof import.meta.url === "string" && import.meta.url.length > 0 ? import.meta.url : null;
1122
+ if (moduleUrl) {
1123
+ for (const relativePath of ["./package.json", "../package.json", "../../package.json"]) {
1124
+ try {
1125
+ const pkg = JSON.parse(readFileSync3(new URL(relativePath, moduleUrl), "utf8"));
1126
+ if (pkg.name === "perplexity-user-mcp" && typeof pkg.version === "string" && pkg.version.length > 0) {
1127
+ cachedPackageVersion = pkg.version;
1128
+ return cachedPackageVersion;
1129
+ }
1130
+ } catch {
1131
+ }
1132
+ }
1133
+ }
1134
+ cachedPackageVersion = typeof process.env.npm_package_version === "string" && process.env.npm_package_version.length > 0 ? process.env.npm_package_version : "0.0.0";
1135
+ return cachedPackageVersion;
1136
+ }
1137
+
1138
+ // src/daemon/oauth-provider.ts
1139
+ import crypto from "crypto";
1140
+ import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
1141
+ import { dirname as dirname3, join as join4 } from "path";
1142
+
1143
+ // src/daemon/oauth-consent-cache.ts
1144
+ import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4 } from "fs";
1145
+ import { spawnSync } from "child_process";
1146
+ import { dirname as dirname2, join as join3 } from "path";
1147
+ function getConsentCachePath(configDir = getConfigDir()) {
1148
+ return join3(configDir, "oauth-consent.json");
1149
+ }
1150
+ function resolvePath(options) {
1151
+ return options.cachePath ?? getConsentCachePath();
1152
+ }
1153
+ function resolveNow(options) {
1154
+ return (options.now ?? Date.now)();
1155
+ }
1156
+ function load(cachePath) {
1157
+ if (!existsSync2(cachePath)) return [];
1158
+ try {
1159
+ const raw = JSON.parse(readFileSync4(cachePath, "utf8"));
1160
+ if (!Array.isArray(raw)) return [];
1161
+ return raw.filter(
1162
+ (e) => e && typeof e === "object" && typeof e.clientId === "string" && typeof e.redirectUri === "string" && typeof e.approvedAt === "string" && typeof e.expiresAt === "number"
1163
+ ).map((e) => ({
1164
+ // Normalize legacy entries (pre-H12 cache files) that lack `resource`.
1165
+ // Absent → undefined (unbound); a non-string → coerce to undefined
1166
+ // so a corrupt record can't accidentally match a bound resource.
1167
+ clientId: e.clientId,
1168
+ redirectUri: e.redirectUri,
1169
+ approvedAt: e.approvedAt,
1170
+ expiresAt: e.expiresAt,
1171
+ resource: typeof e.resource === "string" ? e.resource : void 0
1172
+ }));
1173
+ } catch {
1174
+ return [];
1175
+ }
1176
+ }
1177
+ function keyMatches(entry, clientId, redirectUri, resource) {
1178
+ return entry.clientId === clientId && entry.redirectUri === redirectUri && entry.resource === resource;
1179
+ }
1180
+ function persist(cachePath, entries) {
1181
+ mkdirSync3(dirname2(cachePath), { recursive: true });
1182
+ safeAtomicWriteFileSync(cachePath, JSON.stringify(entries, null, 2) + "\n", { encoding: "utf8", mode: 384 });
1183
+ applyPrivatePermissions(cachePath);
1184
+ }
1185
+ function record(clientId, redirectUri, ttlMs, options = {}) {
1186
+ const cachePath = resolvePath(options);
1187
+ const now = resolveNow(options);
1188
+ const entry = {
1189
+ clientId,
1190
+ redirectUri,
1191
+ approvedAt: new Date(now).toISOString(),
1192
+ expiresAt: now + ttlMs,
1193
+ ...options.resource !== void 0 ? { resource: options.resource } : {}
1194
+ };
1195
+ const all = load(cachePath).filter((e) => !keyMatches(e, clientId, redirectUri, options.resource)).filter((e) => e.expiresAt > now);
1196
+ all.push(entry);
1197
+ persist(cachePath, all);
1198
+ return entry;
1199
+ }
1200
+ function check(clientId, redirectUri, options = {}) {
1201
+ const cachePath = resolvePath(options);
1202
+ const now = resolveNow(options);
1203
+ const all = load(cachePath);
1204
+ const live = all.filter((e) => e.expiresAt > now);
1205
+ if (live.length !== all.length) {
1206
+ persist(cachePath, live);
1207
+ }
1208
+ return live.some((e) => keyMatches(e, clientId, redirectUri, options.resource));
1209
+ }
1210
+ function list2(options = {}) {
1211
+ const cachePath = resolvePath(options);
1212
+ const now = resolveNow(options);
1213
+ const all = load(cachePath);
1214
+ const live = all.filter((e) => e.expiresAt > now);
1215
+ if (live.length !== all.length) {
1216
+ persist(cachePath, live);
1217
+ }
1218
+ return live.slice().sort((a, b) => b.expiresAt - a.expiresAt);
1219
+ }
1220
+ function revoke(options = {}) {
1221
+ const cachePath = resolvePath(options);
1222
+ const now = resolveNow(options);
1223
+ const all = load(cachePath).filter((e) => e.expiresAt > now);
1224
+ const kept = all.filter((e) => {
1225
+ if (!options.clientId) return false;
1226
+ if (e.clientId !== options.clientId) return true;
1227
+ if (options.redirectUri !== void 0 && e.redirectUri !== options.redirectUri) return true;
1228
+ if (options.resource !== void 0 && e.resource !== options.resource) return true;
1229
+ return false;
1230
+ });
1231
+ const removed = all.length - kept.length;
1232
+ if (removed > 0) {
1233
+ persist(cachePath, kept);
1234
+ }
1235
+ return removed;
1236
+ }
1237
+ function applyPrivatePermissions(cachePath) {
1238
+ if (process.platform === "win32") {
1239
+ restrictWindowsAcl(cachePath);
1240
+ return;
1241
+ }
1242
+ chmodSync(cachePath, 384);
1243
+ }
1244
+ function restrictWindowsAcl(cachePath) {
1245
+ const username = getWindowsUserName();
1246
+ const grantTarget = `${username}:(R,W)`;
1247
+ const result = spawnSync("icacls", [cachePath, "/inheritance:r", "/grant:r", grantTarget], {
1248
+ encoding: "utf8",
1249
+ windowsHide: true
1250
+ });
1251
+ if (result.status !== 0) {
1252
+ return;
1253
+ }
1254
+ }
1255
+ function getWindowsUserName() {
1256
+ const username = process.env.USERNAME;
1257
+ const domain = process.env.USERDOMAIN;
1258
+ if (domain && username) return `${domain}\\${username}`;
1259
+ if (username) return username;
1260
+ return "";
1261
+ }
1262
+
1263
+ // src/daemon/oauth-provider.ts
1264
+ var CODE_TTL_MS = 2 * 6e4;
1265
+ var TOKEN_TTL_MS = 60 * 6e4;
1266
+ var STATIC_CLIENT_ID = "local-static";
1267
+ function normalizeResource(input) {
1268
+ if (input === void 0 || input === null) return void 0;
1269
+ if (typeof input === "string" && input.length > 0) return input;
1270
+ if (input instanceof URL) return input.toString().replace(/\/$/, "");
1271
+ return void 0;
1272
+ }
1273
+ var PerplexityOAuthProvider = class {
1274
+ constructor(options) {
1275
+ this.options = options;
1276
+ this.clientsPath = join4(options.configDir, "oauth-clients.json");
1277
+ this.consentCachePath = join4(options.configDir, "oauth-consent.json");
1278
+ this.loadClients();
1279
+ }
1280
+ codes = /* @__PURE__ */ new Map();
1281
+ tokens = /* @__PURE__ */ new Map();
1282
+ clients = /* @__PURE__ */ new Map();
1283
+ clientsPath;
1284
+ consentCachePath;
1285
+ get clientsStore() {
1286
+ return {
1287
+ getClient: (id) => this.clients.get(id),
1288
+ registerClient: async (client) => {
1289
+ const full = {
1290
+ ...client,
1291
+ client_id: `pplx-${crypto.randomBytes(12).toString("base64url")}`,
1292
+ client_id_issued_at: Math.floor(Date.now() / 1e3)
1293
+ };
1294
+ this.clients.set(full.client_id, full);
1295
+ this.persistClients();
1296
+ return full;
1297
+ }
1298
+ };
1299
+ }
1300
+ async authorize(client, params, res) {
1301
+ const ttlMs = this.options.getConsentCacheTtlMs?.() ?? 0;
1302
+ const resource = normalizeResource(params.resource);
1303
+ const cacheHit = ttlMs > 0 && check(client.client_id, params.redirectUri, {
1304
+ cachePath: this.consentCachePath,
1305
+ resource
1306
+ });
1307
+ if (cacheHit) {
1308
+ console.error(`[trace] oauth consent cache hit clientId=${client.client_id} redirectUri=${params.redirectUri} resource=${resource ?? "<unbound>"}`);
1309
+ try {
1310
+ this.options.onConsentCacheHit?.({
1311
+ clientId: client.client_id,
1312
+ redirectUri: params.redirectUri,
1313
+ res
1314
+ });
1315
+ } catch {
1316
+ }
1317
+ return this.issueAuthorizationCode(client, params, res);
1318
+ }
1319
+ const consentId = crypto.randomBytes(8).toString("base64url");
1320
+ try {
1321
+ const approved = await this.options.requestConsent({
1322
+ clientId: client.client_id,
1323
+ clientName: client.client_name ?? client.client_id,
1324
+ redirectUri: params.redirectUri,
1325
+ consentId,
1326
+ resource
1327
+ });
1328
+ if (!approved) {
1329
+ return redirectTo(res, params.redirectUri, { error: "access_denied", state: params.state });
1330
+ }
1331
+ if (ttlMs > 0) {
1332
+ try {
1333
+ record(client.client_id, params.redirectUri, ttlMs, {
1334
+ cachePath: this.consentCachePath,
1335
+ resource
1336
+ });
1337
+ } catch {
1338
+ }
1339
+ }
1340
+ return this.issueAuthorizationCode(client, params, res);
1341
+ } catch (err) {
1342
+ return redirectTo(res, params.redirectUri, {
1343
+ error: "server_error",
1344
+ error_description: err instanceof Error ? err.message : String(err),
1345
+ state: params.state
1346
+ });
1347
+ }
1348
+ }
1349
+ issueAuthorizationCode(client, params, res) {
1350
+ const code = `pplx_ac_${crypto.randomBytes(24).toString("base64url")}`;
1351
+ const resource = normalizeResource(params.resource);
1352
+ this.codes.set(code, {
1353
+ clientId: client.client_id,
1354
+ redirectUri: params.redirectUri,
1355
+ codeChallenge: params.codeChallenge,
1356
+ scopes: params.scopes ?? [],
1357
+ exp: Date.now() + CODE_TTL_MS,
1358
+ resource
1359
+ });
1360
+ const stored = this.clients.get(client.client_id);
1361
+ if (stored) {
1362
+ stored.consent_last_approved_at = (/* @__PURE__ */ new Date()).toISOString();
1363
+ this.persistClients();
1364
+ }
1365
+ return redirectTo(res, params.redirectUri, { code, state: params.state });
1366
+ }
1367
+ async challengeForAuthorizationCode(_client, authorizationCode) {
1368
+ const entry = this.codes.get(authorizationCode);
1369
+ if (!entry) throw new Error("Invalid authorization code.");
1370
+ if (entry.exp < Date.now()) {
1371
+ this.codes.delete(authorizationCode);
1372
+ throw new Error("Authorization code expired.");
1373
+ }
1374
+ return entry.codeChallenge;
1375
+ }
1376
+ async exchangeAuthorizationCode(client, authorizationCode, codeVerifier, redirectUri, resource) {
1377
+ const entry = this.codes.get(authorizationCode);
1378
+ if (!entry) throw new Error("Invalid authorization code.");
1379
+ if (entry.clientId !== client.client_id) throw new Error("Authorization code does not belong to this client.");
1380
+ if (entry.exp < Date.now()) {
1381
+ this.codes.delete(authorizationCode);
1382
+ throw new Error("Authorization code expired.");
1383
+ }
1384
+ if (redirectUri && entry.redirectUri !== redirectUri) {
1385
+ throw new Error("redirect_uri does not match the code's registered redirect.");
1386
+ }
1387
+ const requestedResource = normalizeResource(resource);
1388
+ if (entry.resource && requestedResource && entry.resource !== requestedResource) {
1389
+ throw new Error("Token exchange resource does not match authorized resource.");
1390
+ }
1391
+ void codeVerifier;
1392
+ this.codes.delete(authorizationCode);
1393
+ const tokens = this.issueTokenPair(client.client_id, entry.scopes, entry.resource ?? requestedResource);
1394
+ const stored = this.clients.get(client.client_id);
1395
+ if (stored) {
1396
+ stored.last_used_at = (/* @__PURE__ */ new Date()).toISOString();
1397
+ this.persistClients();
1398
+ }
1399
+ return tokens;
1400
+ }
1401
+ async exchangeRefreshToken(client, refreshToken, scopes, resource) {
1402
+ let matched = null;
1403
+ for (const [at, rec] of this.tokens.entries()) {
1404
+ if (rec.refreshToken === refreshToken && rec.clientId === client.client_id) {
1405
+ matched = [at, rec];
1406
+ break;
1407
+ }
1408
+ }
1409
+ if (!matched) throw new Error("Invalid refresh token.");
1410
+ const requestedResource = normalizeResource(resource);
1411
+ if (matched[1].resource && requestedResource && matched[1].resource !== requestedResource) {
1412
+ throw new Error("Refresh resource does not match token's bound resource.");
1413
+ }
1414
+ const effectiveResource = matched[1].resource ?? requestedResource;
1415
+ this.tokens.delete(matched[0]);
1416
+ const tokens = this.issueTokenPair(client.client_id, scopes ?? matched[1].scopes, effectiveResource);
1417
+ const stored = this.clients.get(client.client_id);
1418
+ if (stored) {
1419
+ stored.last_used_at = (/* @__PURE__ */ new Date()).toISOString();
1420
+ this.persistClients();
1421
+ }
1422
+ return tokens;
1423
+ }
1424
+ async verifyAccessToken(token, source = "loopback", expectedResource) {
1425
+ if (token === this.options.getStaticBearer()) {
1426
+ if (source === "tunnel") throw new Error("static bearer not valid on tunnel");
1427
+ return {
1428
+ token,
1429
+ clientId: STATIC_CLIENT_ID,
1430
+ scopes: ["local"],
1431
+ expiresAt: Math.floor((Date.now() + TOKEN_TTL_MS) / 1e3)
1432
+ };
1433
+ }
1434
+ const rec = this.tokens.get(token);
1435
+ if (!rec) throw new Error("Invalid access token.");
1436
+ if (rec.exp < Date.now()) {
1437
+ this.tokens.delete(token);
1438
+ throw new Error("Access token expired.");
1439
+ }
1440
+ if (rec.resource && expectedResource && rec.resource !== expectedResource) {
1441
+ throw new Error(`Access token resource mismatch: token bound to ${rec.resource}, request expects ${expectedResource}.`);
1442
+ }
1443
+ if (!rec.resource && source === "tunnel") {
1444
+ throw new Error("resource binding required over tunnel");
1445
+ }
1446
+ return {
1447
+ token,
1448
+ clientId: rec.clientId,
1449
+ scopes: rec.scopes,
1450
+ expiresAt: Math.floor(rec.exp / 1e3),
1451
+ extra: {
1452
+ ...rec.resource ? { resource: rec.resource } : { unboundResource: true }
1453
+ }
1454
+ };
1455
+ }
1456
+ async revokeToken(_client, request) {
1457
+ const target = request.token;
1458
+ if (!target) return;
1459
+ if (this.tokens.delete(target)) return;
1460
+ for (const [at, rec] of this.tokens.entries()) {
1461
+ if (rec.refreshToken === target) {
1462
+ this.tokens.delete(at);
1463
+ return;
1464
+ }
1465
+ }
1466
+ }
1467
+ listClients() {
1468
+ const now = Date.now();
1469
+ return [...this.clients.values()].map((c) => ({
1470
+ clientId: c.client_id,
1471
+ clientName: c.client_name,
1472
+ registeredAt: c.client_id_issued_at ?? 0,
1473
+ lastUsedAt: c.last_used_at,
1474
+ consentLastApprovedAt: c.consent_last_approved_at,
1475
+ activeTokens: [...this.tokens.values()].filter((t) => t.clientId === c.client_id && t.exp >= now).length
1476
+ }));
1477
+ }
1478
+ revokeClient(clientId) {
1479
+ if (!this.clients.has(clientId)) return false;
1480
+ this.clients.delete(clientId);
1481
+ for (const [at, rec] of this.tokens.entries()) {
1482
+ if (rec.clientId === clientId) {
1483
+ this.tokens.delete(at);
1484
+ }
1485
+ }
1486
+ this.persistClients();
1487
+ try {
1488
+ revoke({ cachePath: this.consentCachePath, clientId });
1489
+ } catch {
1490
+ }
1491
+ return true;
1492
+ }
1493
+ /**
1494
+ * Revoke every registered OAuth client and invalidate all outstanding
1495
+ * access/refresh tokens. Returns the count of clients that were removed.
1496
+ * Cached consents for every revoked client are also purged so a
1497
+ * future registration with a colliding id cannot silently inherit them.
1498
+ */
1499
+ revokeAllClients() {
1500
+ const ids = [...this.clients.keys()];
1501
+ if (ids.length === 0) return 0;
1502
+ this.clients.clear();
1503
+ this.tokens.clear();
1504
+ this.persistClients();
1505
+ for (const id of ids) {
1506
+ try {
1507
+ revoke({ cachePath: this.consentCachePath, clientId: id });
1508
+ } catch {
1509
+ }
1510
+ }
1511
+ return ids.length;
1512
+ }
1513
+ listConsents() {
1514
+ return list2({ cachePath: this.consentCachePath });
1515
+ }
1516
+ revokeConsent(clientId, redirectUri) {
1517
+ return revoke({ cachePath: this.consentCachePath, clientId, redirectUri });
1518
+ }
1519
+ issueTokenPair(clientId, scopes, resource) {
1520
+ const accessToken = `pplx_at_${crypto.randomBytes(24).toString("base64url")}`;
1521
+ const refreshToken = `pplx_rt_${crypto.randomBytes(24).toString("base64url")}`;
1522
+ const exp = Date.now() + TOKEN_TTL_MS;
1523
+ this.tokens.set(accessToken, {
1524
+ clientId,
1525
+ scopes,
1526
+ exp,
1527
+ refreshToken,
1528
+ resource
1529
+ });
1530
+ return {
1531
+ access_token: accessToken,
1532
+ token_type: "Bearer",
1533
+ expires_in: Math.floor(TOKEN_TTL_MS / 1e3),
1534
+ refresh_token: refreshToken,
1535
+ scope: scopes.join(" ") || void 0
1536
+ };
1537
+ }
1538
+ loadClients() {
1539
+ if (!existsSync3(this.clientsPath)) return;
1540
+ try {
1541
+ const raw = JSON.parse(readFileSync5(this.clientsPath, "utf8"));
1542
+ for (const c of raw) {
1543
+ if (c && typeof c.client_id === "string") {
1544
+ this.clients.set(c.client_id, c);
1545
+ }
1546
+ }
1547
+ } catch {
1548
+ }
1549
+ }
1550
+ persistClients() {
1551
+ try {
1552
+ mkdirSync4(dirname3(this.clientsPath), { recursive: true });
1553
+ writeFileSync3(this.clientsPath, JSON.stringify([...this.clients.values()], null, 2), { mode: 384 });
1554
+ } catch {
1555
+ }
1556
+ }
1557
+ };
1558
+ function redirectTo(res, redirectUri, params) {
1559
+ const url = new URL(redirectUri);
1560
+ for (const [key, value] of Object.entries(params)) {
1561
+ if (typeof value === "string" && value.length > 0) {
1562
+ url.searchParams.set(key, value);
1563
+ }
1564
+ }
1565
+ res.redirect(url.toString());
1566
+ }
1567
+ var ConsentCoordinator = class {
1568
+ pending = /* @__PURE__ */ new Map();
1569
+ request(options) {
1570
+ return new Promise((resolve) => {
1571
+ const timer = setTimeout(() => {
1572
+ this.pending.delete(options.id);
1573
+ resolve(false);
1574
+ }, options.timeoutMs);
1575
+ this.pending.set(options.id, {
1576
+ resolve,
1577
+ timer,
1578
+ info: {
1579
+ clientId: options.clientId,
1580
+ clientName: options.clientName,
1581
+ redirectUri: options.redirectUri,
1582
+ ...options.resource !== void 0 ? { resource: options.resource } : {}
1583
+ }
1584
+ });
1585
+ options.onRequest();
1586
+ });
1587
+ }
1588
+ resolve(id, approved) {
1589
+ const pending = this.pending.get(id);
1590
+ if (!pending) return false;
1591
+ clearTimeout(pending.timer);
1592
+ pending.resolve(approved);
1593
+ this.pending.delete(id);
1594
+ return true;
1595
+ }
1596
+ list() {
1597
+ return [...this.pending.entries()].map(([id, p]) => ({
1598
+ id,
1599
+ clientId: p.info.clientId,
1600
+ clientName: p.info.clientName,
1601
+ redirectUri: p.info.redirectUri,
1602
+ ...p.info.resource !== void 0 ? { resource: p.info.resource } : {}
1603
+ }));
1604
+ }
1605
+ };
1606
+
1607
+ // src/daemon/public-pages.ts
1608
+ var HOMEPAGE_HTML = `<!doctype html>
1609
+ <html lang="en">
1610
+ <head>
1611
+ <meta charset="utf-8">
1612
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1613
+ <meta name="robots" content="noindex,nofollow">
1614
+ <title>Perplexity MCP Server</title>
1615
+ <style>
1616
+ :root { color-scheme: light dark; }
1617
+ html, body { height: 100%; margin: 0; }
1618
+ body {
1619
+ display: flex; align-items: center; justify-content: center;
1620
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
1621
+ background: radial-gradient(ellipse at top, #1a1333 0%, #0a0a1a 60%, #000 100%);
1622
+ color: #e7e5f7;
1623
+ }
1624
+ .card {
1625
+ max-width: 520px; padding: 40px; border-radius: 16px;
1626
+ background: rgba(30, 20, 60, 0.55); backdrop-filter: blur(12px);
1627
+ border: 1px solid rgba(200, 180, 255, 0.18);
1628
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
1629
+ }
1630
+ h1 { margin: 0 0 8px; font-size: 22px; font-weight: 600; letter-spacing: -0.02em; }
1631
+ .eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: #a69ac2; margin-bottom: 18px; }
1632
+ p { font-size: 14px; line-height: 1.55; color: #cfc8e6; margin: 10px 0; }
1633
+ code { background: rgba(255,255,255,0.08); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
1634
+ .muted { font-size: 12px; color: #8e83a8; margin-top: 20px; }
1635
+ </style>
1636
+ </head>
1637
+ <body>
1638
+ <div class="card">
1639
+ <div class="eyebrow">Perplexity MCP</div>
1640
+ <h1>This endpoint hosts a Model Context Protocol server.</h1>
1641
+ <p>It's a personal tool run locally and optionally exposed through a Cloudflare Quick Tunnel. It's not a public service and there's nothing to see here as an anonymous visitor.</p>
1642
+ <p>If you reached this URL by mistake, you can close this tab. If you're trying to integrate an MCP client, see the project documentation for the correct client configuration (the <code>/mcp</code> endpoint speaks the MCP Streamable HTTP transport).</p>
1643
+ <p class="muted">No requests to this origin are logged beyond the request method, path, status, and a coarse timestamp.</p>
1644
+ </div>
1645
+ </body>
1646
+ </html>
1647
+ `;
1648
+ var ROBOTS_TXT = `User-agent: *
1649
+ Disallow: /
1650
+ `;
1651
+ function getHomepageHtml() {
1652
+ return HOMEPAGE_HTML;
1653
+ }
1654
+ function getRobotsTxt() {
1655
+ return ROBOTS_TXT;
1656
+ }
1657
+
1658
+ // src/daemon/security.ts
1659
+ import { setTimeout as delay } from "timers/promises";
1660
+ var DEFAULT_UA_BLOCKLIST = [
1661
+ /\bmasscan\b/i,
1662
+ /\bnmap\b/i,
1663
+ /\bzgrab\b/i,
1664
+ /\bzmap\b/i,
1665
+ /\bsqlmap\b/i,
1666
+ /\bnikto\b/i,
1667
+ /\bgobuster\b/i,
1668
+ /\bdirbuster\b/i,
1669
+ /\bwpscan\b/i,
1670
+ /\bhydra\b/i,
1671
+ /\bcensys\b/i,
1672
+ /\bShodan\b/i
1673
+ ];
1674
+ function createSecurity(options = {}) {
1675
+ const ratelimitRpm = parseRpmEnv() ?? options.ratelimitRpm ?? 60;
1676
+ const tripwireWindowMs = options.tripwireWindowMs ?? 6e4;
1677
+ const tripwireThreshold = options.tripwireThreshold ?? 20;
1678
+ const slow401Ms = options.slow401Ms ?? 150;
1679
+ const uaBlocklist = options.uaBlocklist ?? DEFAULT_UA_BLOCKLIST;
1680
+ const bearerBuckets = /* @__PURE__ */ new Map();
1681
+ const tripwireEvents = [];
1682
+ let rateLimitedBearers = 0;
1683
+ let blockedUas = 0;
1684
+ let tripwireLatched = false;
1685
+ const middleware = (req, res, next) => {
1686
+ const ip = pickClientIp(req);
1687
+ const ua = typeof req.headers?.["user-agent"] === "string" ? req.headers["user-agent"] : "";
1688
+ const bearer = extractBearer(req.headers?.authorization);
1689
+ const existing = req._pplx ?? {};
1690
+ const source = existing.source === "tunnel" || existing.source === "loopback" ? existing.source : isLoopbackRequest(req, ip) ? "loopback" : "tunnel";
1691
+ req._pplx = {
1692
+ ...existing,
1693
+ ip,
1694
+ userAgent: ua,
1695
+ source,
1696
+ bearer,
1697
+ startedAt: Date.now()
1698
+ };
1699
+ if (source === "tunnel" && ua && uaBlocklist.some((re) => re.test(ua))) {
1700
+ blockedUas += 1;
1701
+ res.status(403).json({ error: "Forbidden (user-agent blocklist)" });
1702
+ return;
1703
+ }
1704
+ if (source === "tunnel" && bearer) {
1705
+ const now = Date.now();
1706
+ const bucket = bearerBuckets.get(bearer) ?? [];
1707
+ const cutoff = now - 6e4;
1708
+ const fresh = bucket.filter((ts) => ts >= cutoff);
1709
+ if (fresh.length >= ratelimitRpm) {
1710
+ rateLimitedBearers += 1;
1711
+ res.setHeader("Retry-After", "60");
1712
+ res.status(429).json({ error: "Too Many Requests" });
1713
+ return;
1714
+ }
1715
+ fresh.push(now);
1716
+ bearerBuckets.set(bearer, fresh);
1717
+ }
1718
+ const originalStatus = res.status.bind(res);
1719
+ let slowPending = false;
1720
+ res.status = (code) => {
1721
+ if (code === 401 && source === "tunnel") {
1722
+ slowPending = true;
1723
+ record401({ source, ip });
1724
+ }
1725
+ return originalStatus(code);
1726
+ };
1727
+ if (slow401Ms > 0) {
1728
+ const originalEnd = res.end.bind(res);
1729
+ res.end = (...args) => {
1730
+ if (slowPending) {
1731
+ void delay(slow401Ms).then(() => originalEnd(...args));
1732
+ return res;
1733
+ }
1734
+ return originalEnd(...args);
1735
+ };
1736
+ }
1737
+ next();
1738
+ };
1739
+ const record401 = (info) => {
1740
+ if (info.source !== "tunnel") {
1741
+ return;
1742
+ }
1743
+ const now = Date.now();
1744
+ tripwireEvents.push(now);
1745
+ const cutoff = now - tripwireWindowMs;
1746
+ while (tripwireEvents.length > 0 && tripwireEvents[0] < cutoff) {
1747
+ tripwireEvents.shift();
1748
+ }
1749
+ if (!tripwireLatched && tripwireEvents.length >= tripwireThreshold) {
1750
+ tripwireLatched = true;
1751
+ const failures = tripwireEvents.length;
1752
+ tripwireEvents.length = 0;
1753
+ queueMicrotask(() => {
1754
+ void options.onTripwireTriggered?.({
1755
+ source: info.source,
1756
+ failures,
1757
+ windowMs: tripwireWindowMs,
1758
+ ip: info.ip
1759
+ });
1760
+ });
1761
+ }
1762
+ };
1763
+ const snapshot = () => ({
1764
+ tripwireFailures: tripwireEvents.length,
1765
+ tripwireWindowMs,
1766
+ tripwireThreshold,
1767
+ rateLimitedBearers,
1768
+ blockedUas
1769
+ });
1770
+ return { middleware, record401, snapshot };
1771
+ }
1772
+ function pickClientIp(req) {
1773
+ const xff = typeof req.headers?.["x-forwarded-for"] === "string" ? req.headers["x-forwarded-for"] : null;
1774
+ if (xff) {
1775
+ return xff.split(",")[0].trim();
1776
+ }
1777
+ const cfip = typeof req.headers?.["cf-connecting-ip"] === "string" ? req.headers["cf-connecting-ip"] : null;
1778
+ if (cfip) return cfip;
1779
+ return req.ip ?? req.connection?.remoteAddress ?? req.socket?.remoteAddress ?? null;
1780
+ }
1781
+ function isLoopbackRequest(req, ip) {
1782
+ if (req.headers?.["x-perplexity-source"] === "loopback") return true;
1783
+ if (ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1") {
1784
+ if (req.headers?.["cf-connecting-ip"]) return false;
1785
+ return true;
1786
+ }
1787
+ return false;
1788
+ }
1789
+ function extractBearer(header) {
1790
+ if (typeof header !== "string") return null;
1791
+ const match = header.match(/^Bearer\s+(.+)$/i);
1792
+ return match?.[1] ?? null;
1793
+ }
1794
+ function parseRpmEnv() {
1795
+ const raw = process.env.PERPLEXITY_DAEMON_RATELIMIT_RPM;
1796
+ if (!raw) return null;
1797
+ const n = Number.parseInt(raw, 10);
1798
+ return Number.isFinite(n) && n > 0 ? n : null;
1799
+ }
1800
+
1801
+ // src/daemon/server.ts
1802
+ async function startDaemonServer(options = {}) {
1803
+ const host = options.host ?? "127.0.0.1";
1804
+ const requestedPort = options.port ?? 0;
1805
+ const version = options.version ?? getPackageVersion();
1806
+ const auditPath = getAuditLogPath(options.configDir);
1807
+ const tokenPath = getTokenPath(options.configDir);
1808
+ const initialToken = options.bearerToken ? {
1809
+ bearerToken: options.bearerToken,
1810
+ version: 1,
1811
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1812
+ rotatedAt: (/* @__PURE__ */ new Date()).toISOString()
1813
+ } : ensureToken({ tokenPath });
1814
+ let currentToken = initialToken;
1815
+ let closed = false;
1816
+ let client;
1817
+ let clientInitPromise = null;
1818
+ const consentCoordinator = new ConsentCoordinator();
1819
+ const oauthProvider = new PerplexityOAuthProvider({
1820
+ configDir: options.configDir ?? ".",
1821
+ getStaticBearer: () => currentToken.bearerToken,
1822
+ getConsentCacheTtlMs: () => {
1823
+ const raw = Number(process.env.PERPLEXITY_OAUTH_CONSENT_TTL_HOURS);
1824
+ const hours = Number.isFinite(raw) && raw >= 0 ? raw : 24;
1825
+ return Math.floor(hours * 60 * 6e4);
1826
+ },
1827
+ onConsentCacheHit: ({ res }) => {
1828
+ const req = res.req;
1829
+ if (req) {
1830
+ req._pplx = req._pplx ?? {};
1831
+ req._pplx.authOverride = "oauth-cached";
1832
+ }
1833
+ },
1834
+ requestConsent: ({ clientId, clientName, redirectUri, consentId, resource }) => {
1835
+ return consentCoordinator.request({
1836
+ id: consentId,
1837
+ clientId,
1838
+ clientName,
1839
+ redirectUri,
1840
+ resource,
1841
+ timeoutMs: 2 * 6e4,
1842
+ onRequest: () => {
1843
+ void options.onOAuthConsentRequest?.({ consentId, clientId, clientName, redirectUri, resource });
1844
+ publishEvent("daemon:oauth-consent-request", { consentId, clientId, clientName, redirectUri, resource });
1845
+ }
1846
+ });
1847
+ }
1848
+ });
1849
+ let httpServer;
1850
+ const startedAt = Date.now();
1851
+ const heartbeatMap = /* @__PURE__ */ new Map();
1852
+ const sseClients = /* @__PURE__ */ new Set();
1853
+ const activeMcpClosers = /* @__PURE__ */ new Set();
1854
+ const expressFactory = express;
1855
+ const app = expressFactory();
1856
+ const getClient = async () => {
1857
+ if (!client) {
1858
+ client = options.createClient ? options.createClient() : new PerplexityClient();
1859
+ }
1860
+ if (!clientInitPromise) {
1861
+ const pending = client.init();
1862
+ pending.catch(() => {
1863
+ client = void 0;
1864
+ clientInitPromise = null;
1865
+ });
1866
+ clientInitPromise = pending;
1867
+ }
1868
+ await clientInitPromise;
1869
+ return client;
1870
+ };
1871
+ app.set?.("trust proxy", 1);
1872
+ app.use((req, _res, next) => {
1873
+ req._pplx = req._pplx ?? {};
1874
+ req._pplx.source = computeRequestSource(req);
1875
+ const declared = req.headers?.["x-perplexity-source"];
1876
+ if (typeof declared === "string") req._pplx.declaredSource = declared;
1877
+ next();
1878
+ });
1879
+ app.use((req, res, next) => {
1880
+ if (req._pplx?.source !== "tunnel") {
1881
+ next();
1882
+ return;
1883
+ }
1884
+ const path = typeof req.originalUrl === "string" ? req.originalUrl : req.url ?? "";
1885
+ if (!pathIsTunnelAllowed(path)) {
1886
+ res.status(404).end();
1887
+ return;
1888
+ }
1889
+ next();
1890
+ });
1891
+ app.use(
1892
+ helmet({
1893
+ contentSecurityPolicy: false,
1894
+ // SDK's OAuth handlers + our homepage serve inline styles; CSP would need a full policy pass
1895
+ crossOriginEmbedderPolicy: false,
1896
+ crossOriginOpenerPolicy: false,
1897
+ crossOriginResourcePolicy: false,
1898
+ // Tunnel front (Cloudflare / ngrok) supplies TLS; our origin is HTTP.
1899
+ // HSTS from the origin would be inaccurate; let the edge control it.
1900
+ hsts: false
1901
+ })
1902
+ );
1903
+ app.use(expressFactory.json({ limit: "1mb" }));
1904
+ const security = createSecurity({
1905
+ onTripwireTriggered: async (info) => {
1906
+ console.error(`[trace] 401-burst tripwire fired: ${info.failures} failures in ${info.windowMs}ms`);
1907
+ try {
1908
+ publishEvent("daemon:tunnel-auto-disabled", {
1909
+ failures: info.failures,
1910
+ windowMs: info.windowMs,
1911
+ ip: info.ip ?? null
1912
+ });
1913
+ } catch {
1914
+ }
1915
+ await options.onTunnelAutoDisable?.({ failures: info.failures, windowMs: info.windowMs });
1916
+ }
1917
+ });
1918
+ app.use((req, res, next) => {
1919
+ const startedAtReq = Date.now();
1920
+ const ctx = req._pplx ?? {};
1921
+ res.on("finish", () => {
1922
+ const durationMs = Date.now() - startedAtReq;
1923
+ const rawPath = typeof req.originalUrl === "string" && req.originalUrl.length > 0 ? req.originalUrl : typeof req.path === "string" ? req.path : req.url ?? "";
1924
+ const path = rawPath.split("?")[0] ?? rawPath;
1925
+ const status = res.statusCode;
1926
+ const hasAuth = typeof req.headers?.authorization === "string";
1927
+ console.error(`[trace] http ${req.method} ${path} auth=${hasAuth ? "yes" : "no"} status=${status} dur=${durationMs}ms ip=${ctx.ip ?? "?"} ua=${(ctx.userAgent ?? "").slice(0, 40)}`);
1928
+ if (path.startsWith("/daemon") || path.startsWith("/mcp") || path.startsWith("/authorize") || path.startsWith("/token") || path.startsWith("/register")) {
1929
+ try {
1930
+ const latestCtx = req._pplx ?? ctx;
1931
+ const authTag = latestCtx.authOverride ?? (hasAuth ? "bearer" : "none");
1932
+ appendAuditEntry(
1933
+ {
1934
+ timestamp: new Date(startedAtReq).toISOString(),
1935
+ clientId: ctx.bearer ? "bearer-client" : "anon",
1936
+ tool: `http:${req.method} ${path}`,
1937
+ durationMs,
1938
+ source: ctx.source ?? (hasAuth ? "loopback" : "tunnel"),
1939
+ ok: status >= 200 && status < 400,
1940
+ ip: ctx.ip ?? void 0,
1941
+ userAgent: ctx.userAgent || void 0,
1942
+ path,
1943
+ httpStatus: status,
1944
+ auth: authTag
1945
+ },
1946
+ { auditPath }
1947
+ );
1948
+ } catch {
1949
+ }
1950
+ }
1951
+ });
1952
+ next();
1953
+ });
1954
+ app.use(security.middleware);
1955
+ const requireBearer = (req, res, next) => {
1956
+ const header = readAuthorizationHeader(req.headers?.authorization);
1957
+ if (header !== currentToken.bearerToken) {
1958
+ res.status(401).json({ error: "Unauthorized" });
1959
+ return;
1960
+ }
1961
+ const computedSource = req._pplx?.source === "tunnel" ? "tunnel" : "loopback";
1962
+ req.auth = {
1963
+ token: currentToken.bearerToken,
1964
+ clientId: readSingleHeader(req.headers?.["x-perplexity-client-id"]) ?? "daemon-client",
1965
+ scopes: [],
1966
+ extra: {
1967
+ source: computedSource
1968
+ }
1969
+ };
1970
+ next();
1971
+ };
1972
+ const getHealth = () => ({
1973
+ ok: true,
1974
+ pid: process.pid,
1975
+ uuid: options.uuid ?? null,
1976
+ version,
1977
+ port: getBoundPort(httpServer),
1978
+ uptimeMs: Date.now() - startedAt,
1979
+ startedAt: new Date(startedAt).toISOString(),
1980
+ heartbeatCount: heartbeatMap.size,
1981
+ tunnel: options.getTunnelState?.() ?? {
1982
+ status: "disabled",
1983
+ url: null,
1984
+ pid: null,
1985
+ error: null
1986
+ }
1987
+ });
1988
+ const publishEvent = (event, payload) => {
1989
+ const frame = `event: ${event}
1990
+ data: ${JSON.stringify(payload)}
1991
+
1992
+ `;
1993
+ for (const response of sseClients) {
1994
+ response.write(frame);
1995
+ }
1996
+ };
1997
+ const oauthIssuer = new URL("http://localhost");
1998
+ app.get("/.well-known/oauth-authorization-server", (req, res) => {
1999
+ const issuer = resolveIssuer(req, oauthIssuer);
2000
+ const body = {
2001
+ issuer: issuer.href.replace(/\/$/, ""),
2002
+ authorization_endpoint: new URL("/authorize", issuer).href,
2003
+ token_endpoint: new URL("/token", issuer).href,
2004
+ registration_endpoint: new URL("/register", issuer).href,
2005
+ revocation_endpoint: new URL("/revoke", issuer).href,
2006
+ response_types_supported: ["code"],
2007
+ grant_types_supported: ["authorization_code", "refresh_token"],
2008
+ code_challenge_methods_supported: ["S256"],
2009
+ token_endpoint_auth_methods_supported: ["none"]
2010
+ };
2011
+ res.setHeader("Cache-Control", "no-store");
2012
+ res.json(body);
2013
+ });
2014
+ app.get("/.well-known/oauth-protected-resource", (req, res) => {
2015
+ const issuer = resolveIssuer(req, oauthIssuer);
2016
+ const resource = resolveRequestResource(req, oauthIssuer);
2017
+ res.json({
2018
+ resource,
2019
+ authorization_servers: [issuer.href.replace(/\/$/, "")],
2020
+ scopes_supported: ["mcp"],
2021
+ resource_name: "Perplexity MCP"
2022
+ });
2023
+ });
2024
+ const oauthPathRe = /^\/(authorize|register|token|revoke)\b/;
2025
+ const oauthIpHits = /* @__PURE__ */ new Map();
2026
+ app.use((req, res, next) => {
2027
+ if (!oauthPathRe.test(req.path ?? "")) {
2028
+ next();
2029
+ return;
2030
+ }
2031
+ const ctx = req._pplx ?? {};
2032
+ if (ctx.source === "loopback") {
2033
+ next();
2034
+ return;
2035
+ }
2036
+ const key = ctx.ip ?? "?";
2037
+ const now = Date.now();
2038
+ const bucket = (oauthIpHits.get(key) ?? []).filter((ts) => ts >= now - 6e4);
2039
+ bucket.push(now);
2040
+ oauthIpHits.set(key, bucket);
2041
+ if (bucket.length > 30) {
2042
+ res.setHeader("Retry-After", "60");
2043
+ res.status(429).json({ error: "Too Many Requests" });
2044
+ return;
2045
+ }
2046
+ next();
2047
+ });
2048
+ try {
2049
+ app.use(
2050
+ mcpAuthRouter({
2051
+ provider: oauthProvider,
2052
+ issuerUrl: oauthIssuer
2053
+ })
2054
+ );
2055
+ } catch (err) {
2056
+ console.error("[trace] mcpAuthRouter mount failed:", err instanceof Error ? err.message : String(err));
2057
+ }
2058
+ app.post("/daemon/oauth-consent", requireBearer, (req, res) => {
2059
+ const consentId = typeof req.body?.consentId === "string" ? req.body.consentId : null;
2060
+ const approved = req.body?.approved === true;
2061
+ if (!consentId) {
2062
+ res.status(400).json({ error: "consentId required" });
2063
+ return;
2064
+ }
2065
+ const resolved = consentCoordinator.resolve(consentId, approved);
2066
+ res.json({ ok: resolved });
2067
+ });
2068
+ app.get("/daemon/oauth-consents", requireBearer, (_req, res) => {
2069
+ res.json({ consents: oauthProvider.listConsents() });
2070
+ });
2071
+ app.delete("/daemon/oauth-consents", requireBearer, (req, res) => {
2072
+ const clientId = typeof req.body?.clientId === "string" ? req.body.clientId : void 0;
2073
+ const redirectUri = typeof req.body?.redirectUri === "string" ? req.body.redirectUri : void 0;
2074
+ const removed = oauthProvider.revokeConsent(clientId, redirectUri);
2075
+ res.json({ ok: true, removed });
2076
+ });
2077
+ app.get("/daemon/oauth-clients", requireBearer, (_req, res) => {
2078
+ res.json({ clients: oauthProvider.listClients() });
2079
+ });
2080
+ app.delete("/daemon/oauth-clients", requireBearer, (req, res) => {
2081
+ const clientId = typeof req.query?.clientId === "string" && req.query.clientId.length > 0 ? req.query.clientId : typeof req.body?.clientId === "string" && req.body.clientId.length > 0 ? req.body.clientId : void 0;
2082
+ if (clientId) {
2083
+ const ok = oauthProvider.revokeClient(clientId);
2084
+ res.json({ ok, removed: ok ? 1 : 0 });
2085
+ return;
2086
+ }
2087
+ const removed = oauthProvider.revokeAllClients();
2088
+ res.json({ ok: true, removed });
2089
+ });
2090
+ const looksLikeMcpClient = (req) => {
2091
+ const accept = String(req.headers?.accept ?? "").toLowerCase();
2092
+ const contentType = String(req.headers?.["content-type"] ?? "").toLowerCase();
2093
+ if (req.method === "POST") return true;
2094
+ if (accept.includes("text/event-stream")) return true;
2095
+ if (accept.includes("application/json") && !accept.includes("text/html")) return true;
2096
+ if (contentType.includes("application/json")) return true;
2097
+ return false;
2098
+ };
2099
+ app.all("/", (req, res, next) => {
2100
+ if (looksLikeMcpClient(req)) {
2101
+ return next();
2102
+ }
2103
+ if (req.method !== "GET") {
2104
+ res.status(405).setHeader("Allow", "GET").end();
2105
+ return;
2106
+ }
2107
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
2108
+ res.setHeader("Cache-Control", "public, max-age=3600");
2109
+ res.setHeader("X-Robots-Tag", "noindex, nofollow");
2110
+ res.status(200).end(getHomepageHtml());
2111
+ });
2112
+ app.get("/robots.txt", (_req, res) => {
2113
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
2114
+ res.setHeader("Cache-Control", "public, max-age=86400");
2115
+ res.status(200).end(getRobotsTxt());
2116
+ });
2117
+ app.get("/favicon.ico", (_req, res) => {
2118
+ res.status(204).end();
2119
+ });
2120
+ app.get("/daemon/events", requireBearer, (req, res) => {
2121
+ res.setHeader("Content-Type", "text/event-stream");
2122
+ res.setHeader("Cache-Control", "no-cache");
2123
+ res.setHeader("Connection", "keep-alive");
2124
+ res.flushHeaders?.();
2125
+ res.write(`event: daemon:ready
2126
+ data: ${JSON.stringify(getHealth())}
2127
+
2128
+ `);
2129
+ sseClients.add(res);
2130
+ req.on("close", () => {
2131
+ sseClients.delete(res);
2132
+ });
2133
+ });
2134
+ app.get("/daemon/health", requireBearer, (_req, res) => {
2135
+ res.json(getHealth());
2136
+ });
2137
+ app.post("/daemon/heartbeat", requireBearer, (req, res) => {
2138
+ const clientId = typeof req.body?.clientId === "string" && req.body.clientId.length > 0 ? req.body.clientId : req.auth?.clientId ?? "daemon-client";
2139
+ heartbeatMap.set(clientId, Date.now());
2140
+ res.json({ ok: true, clientId });
2141
+ });
2142
+ app.post("/daemon/rotate-token", requireBearer, async (_req, res, next) => {
2143
+ try {
2144
+ currentToken = rotateToken({ tokenPath });
2145
+ await options.onTokenRotated?.(currentToken);
2146
+ publishEvent("daemon:token-rotated", {
2147
+ rotatedAt: currentToken.rotatedAt,
2148
+ version: currentToken.version
2149
+ });
2150
+ res.json({
2151
+ ok: true,
2152
+ rotatedAt: currentToken.rotatedAt,
2153
+ version: currentToken.version
2154
+ });
2155
+ } catch (error) {
2156
+ next(error);
2157
+ }
2158
+ });
2159
+ app.post("/daemon/shutdown", requireBearer, (req, res, next) => {
2160
+ res.json({ ok: true });
2161
+ setImmediate(() => {
2162
+ close().catch(next);
2163
+ });
2164
+ });
2165
+ app.post("/daemon/enable-tunnel", requireBearer, async (_req, res, next) => {
2166
+ try {
2167
+ await options.onEnableTunnel?.();
2168
+ res.json({ ok: true, tunnel: getHealth().tunnel });
2169
+ } catch (error) {
2170
+ next(error);
2171
+ }
2172
+ });
2173
+ app.post("/daemon/disable-tunnel", requireBearer, async (_req, res, next) => {
2174
+ try {
2175
+ await options.onDisableTunnel?.();
2176
+ res.json({ ok: true, tunnel: getHealth().tunnel });
2177
+ } catch (error) {
2178
+ next(error);
2179
+ }
2180
+ });
2181
+ const requireMcpAuth = async (req, res, next) => {
2182
+ const sendUnauthorized = (error, description) => {
2183
+ const issuer = resolveIssuer(req, oauthIssuer);
2184
+ const prm = new URL("/.well-known/oauth-protected-resource", issuer).href;
2185
+ res.setHeader(
2186
+ "WWW-Authenticate",
2187
+ `Bearer error="${error}", error_description="${description}", resource_metadata="${prm}"`
2188
+ );
2189
+ res.status(401).json({ error, error_description: description });
2190
+ };
2191
+ try {
2192
+ const authHeader = typeof req.headers?.authorization === "string" ? req.headers.authorization : null;
2193
+ if (!authHeader) {
2194
+ return sendUnauthorized("invalid_token", "Missing Authorization header");
2195
+ }
2196
+ const [type, token] = authHeader.split(/\s+/, 2);
2197
+ if (!token || type.toLowerCase() !== "bearer") {
2198
+ return sendUnauthorized("invalid_token", "Expected 'Bearer TOKEN'");
2199
+ }
2200
+ const source = req._pplx?.source === "tunnel" ? "tunnel" : "loopback";
2201
+ const expectedResource = resolveRequestResource(req, oauthIssuer);
2202
+ const info = await oauthProvider.verifyAccessToken(token, source, expectedResource);
2203
+ if (typeof info.expiresAt === "number" && info.expiresAt < Date.now() / 1e3) {
2204
+ return sendUnauthorized("invalid_token", "Token expired");
2205
+ }
2206
+ req.auth = info;
2207
+ next();
2208
+ } catch (err) {
2209
+ const message = err instanceof Error ? err.message : "Invalid token";
2210
+ sendUnauthorized("invalid_token", message);
2211
+ }
2212
+ };
2213
+ const promoteCallerClientId = (req, _res, next) => {
2214
+ try {
2215
+ const auth = req.auth;
2216
+ if (auth && auth.clientId === "local-static") {
2217
+ const header = req.headers?.["x-perplexity-client-id"];
2218
+ const caller = typeof header === "string" ? header : Array.isArray(header) ? header[0] : void 0;
2219
+ if (caller && caller.length > 0) {
2220
+ auth.clientId = caller;
2221
+ }
2222
+ }
2223
+ } catch {
2224
+ }
2225
+ next();
2226
+ };
2227
+ app.all(["/mcp", "/"], requireMcpAuth, promoteCallerClientId, async (req, res, next) => {
2228
+ try {
2229
+ const mcpServer = new McpServer({
2230
+ name: "perplexity",
2231
+ version
2232
+ });
2233
+ const transport = new StreamableHTTPServerTransport({
2234
+ sessionIdGenerator: void 0
2235
+ });
2236
+ registerResources(mcpServer);
2237
+ registerPrompts(mcpServer);
2238
+ registerTools(mcpServer, getClient, getEnabledTools(loadToolConfig()), {
2239
+ onToolSettled: (event) => {
2240
+ appendAuditEntry({
2241
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2242
+ clientId: event.clientId,
2243
+ tool: event.tool,
2244
+ durationMs: event.durationMs,
2245
+ source: event.source,
2246
+ ok: event.ok,
2247
+ ...event.error ? { error: event.error } : {}
2248
+ }, { auditPath });
2249
+ },
2250
+ onToolProgress: (event) => {
2251
+ publishEvent("daemon:tool-progress", { ...event });
2252
+ }
2253
+ });
2254
+ await mcpServer.connect(transport);
2255
+ let cleanedUp = false;
2256
+ const cleanup = async () => {
2257
+ if (cleanedUp) {
2258
+ return;
2259
+ }
2260
+ cleanedUp = true;
2261
+ activeMcpClosers.delete(cleanup);
2262
+ await mcpServer.close().catch(() => void 0);
2263
+ };
2264
+ activeMcpClosers.add(cleanup);
2265
+ res.on("close", () => {
2266
+ void cleanup();
2267
+ });
2268
+ await transport.handleRequest(req, res, req.body);
2269
+ } catch (error) {
2270
+ next(error);
2271
+ }
2272
+ });
2273
+ app.use((error, _req, res, _next) => {
2274
+ res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
2275
+ });
2276
+ httpServer = createServer(app);
2277
+ try {
2278
+ await listenAvoidingBlockedPorts(httpServer, requestedPort, host);
2279
+ } catch (error) {
2280
+ try {
2281
+ httpServer.close();
2282
+ } catch {
2283
+ }
2284
+ httpServer = void 0;
2285
+ throw error;
2286
+ }
2287
+ const runShutdownStep = async (label, fn) => {
2288
+ try {
2289
+ await fn();
2290
+ } catch (err) {
2291
+ const message = err instanceof Error ? err.message : String(err);
2292
+ console.error(`[trace] daemon shutdown step '${label}' failed: ${message}`);
2293
+ }
2294
+ };
2295
+ const close = async () => {
2296
+ if (closed) {
2297
+ return;
2298
+ }
2299
+ closed = true;
2300
+ await runShutdownStep("sse-clients", () => {
2301
+ for (const response of sseClients) {
2302
+ try {
2303
+ response.end();
2304
+ } catch {
2305
+ }
2306
+ }
2307
+ sseClients.clear();
2308
+ });
2309
+ for (const cleanup of Array.from(activeMcpClosers)) {
2310
+ await runShutdownStep("mcp-cleanup", () => cleanup());
2311
+ }
2312
+ await runShutdownStep("client-shutdown", () => client?.shutdown?.() ?? void 0);
2313
+ await runShutdownStep("on-shutdown", () => options.onShutdown?.() ?? void 0);
2314
+ if (httpServer) {
2315
+ await runShutdownStep(
2316
+ "http-close",
2317
+ () => new Promise((resolve, reject) => {
2318
+ httpServer.close((error) => {
2319
+ if (error) {
2320
+ reject(error);
2321
+ return;
2322
+ }
2323
+ resolve();
2324
+ });
2325
+ })
2326
+ );
2327
+ }
2328
+ };
2329
+ return {
2330
+ host,
2331
+ port: getBoundPort(httpServer),
2332
+ url: `http://${host}:${getBoundPort(httpServer)}`,
2333
+ // Live getter: must reflect the CURRENT token after rotation.
2334
+ // A plain snapshot here causes the launcher's syncLockfile to write
2335
+ // the stale pre-rotation bearer back into the lockfile on every
2336
+ // publishTunnelState, breaking auth for probes.
2337
+ get bearerToken() {
2338
+ return currentToken.bearerToken;
2339
+ },
2340
+ auditPath,
2341
+ tokenPath,
2342
+ close,
2343
+ publishEvent,
2344
+ getHealth,
2345
+ readAuditTail: (limit = 50) => readAuditTail(limit, { auditPath }),
2346
+ listOAuthClients: () => oauthProvider.listClients(),
2347
+ revokeOAuthClient: (clientId) => oauthProvider.revokeClient(clientId),
2348
+ revokeAllOAuthClients: () => oauthProvider.revokeAllClients(),
2349
+ resolveOAuthConsent: (consentId, approved) => consentCoordinator.resolve(consentId, approved),
2350
+ listOAuthConsents: () => oauthProvider.listConsents(),
2351
+ revokeOAuthConsents: (filter) => oauthProvider.revokeConsent(filter?.clientId, filter?.redirectUri)
2352
+ };
2353
+ }
2354
+ function computeRequestSource(req) {
2355
+ if (req.headers?.["x-forwarded-for"]) return "tunnel";
2356
+ if (req.headers?.["cf-connecting-ip"]) return "tunnel";
2357
+ const ip = req.ip ?? req.socket?.remoteAddress ?? "";
2358
+ if (ip && ip !== "127.0.0.1" && ip !== "::1" && ip !== "::ffff:127.0.0.1") return "tunnel";
2359
+ return "loopback";
2360
+ }
2361
+ var TUNNEL_ALLOWLIST = [
2362
+ /^\/mcp(\/|$|\?)/,
2363
+ /^\/$/,
2364
+ /^\/authorize(\/|$|\?)/,
2365
+ /^\/token(\/|$|\?)/,
2366
+ /^\/register(\/|$|\?)/,
2367
+ /^\/revoke(\/|$|\?)/,
2368
+ /^\/\.well-known\/(oauth-authorization-server|oauth-protected-resource)(\/|$|\?)/,
2369
+ /^\/robots\.txt$/,
2370
+ /^\/favicon\.ico$/
2371
+ ];
2372
+ function pathIsTunnelAllowed(path) {
2373
+ const bare = path.split("?")[0] ?? path;
2374
+ return TUNNEL_ALLOWLIST.some((re) => re.test(bare));
2375
+ }
2376
+ function resolveIssuer(req, fallback) {
2377
+ const forwardedHostRaw = typeof req.headers?.["x-forwarded-host"] === "string" ? req.headers["x-forwarded-host"] : null;
2378
+ const forwardedHost = forwardedHostRaw ? forwardedHostRaw.split(",")[0].trim() : null;
2379
+ const host = forwardedHost ?? (typeof req.headers?.host === "string" ? req.headers.host : null);
2380
+ const forwardedProto = typeof req.headers?.["x-forwarded-proto"] === "string" ? req.headers["x-forwarded-proto"] : null;
2381
+ const cfConnecting = req.headers?.["cf-connecting-ip"];
2382
+ if (host) {
2383
+ const proto = forwardedProto ?? (cfConnecting ? "https" : "http");
2384
+ try {
2385
+ return new URL(`${proto}://${host}`);
2386
+ } catch {
2387
+ }
2388
+ }
2389
+ return fallback;
2390
+ }
2391
+ function resolveRequestResource(req, fallback = new URL("http://localhost")) {
2392
+ const issuer = resolveIssuer(req, fallback);
2393
+ return new URL("/mcp", issuer).toString().replace(/\/$/, "");
2394
+ }
2395
+ function readAuthorizationHeader(value) {
2396
+ const header = readSingleHeader(value);
2397
+ if (!header) {
2398
+ return null;
2399
+ }
2400
+ const match = header.match(/^Bearer\s+(.+)$/i);
2401
+ return match?.[1] ?? null;
2402
+ }
2403
+ function readSingleHeader(value) {
2404
+ if (Array.isArray(value)) {
2405
+ return value[0] ?? null;
2406
+ }
2407
+ return typeof value === "string" ? value : null;
2408
+ }
2409
+ function getBoundPort(server) {
2410
+ const address = server?.address();
2411
+ if (!address || typeof address === "string") {
2412
+ throw new Error("Daemon server is not listening on a TCP port.");
2413
+ }
2414
+ return address.port;
2415
+ }
2416
+ var FETCH_BLOCKED_PORTS = /* @__PURE__ */ new Set([
2417
+ 1,
2418
+ 7,
2419
+ 9,
2420
+ 11,
2421
+ 13,
2422
+ 15,
2423
+ 17,
2424
+ 19,
2425
+ 20,
2426
+ 21,
2427
+ 22,
2428
+ 23,
2429
+ 25,
2430
+ 37,
2431
+ 42,
2432
+ 43,
2433
+ 53,
2434
+ 69,
2435
+ 77,
2436
+ 79,
2437
+ 87,
2438
+ 95,
2439
+ 101,
2440
+ 102,
2441
+ 103,
2442
+ 104,
2443
+ 109,
2444
+ 110,
2445
+ 111,
2446
+ 113,
2447
+ 115,
2448
+ 117,
2449
+ 119,
2450
+ 123,
2451
+ 135,
2452
+ 137,
2453
+ 139,
2454
+ 143,
2455
+ 161,
2456
+ 179,
2457
+ 389,
2458
+ 427,
2459
+ 465,
2460
+ 512,
2461
+ 513,
2462
+ 514,
2463
+ 515,
2464
+ 526,
2465
+ 530,
2466
+ 531,
2467
+ 532,
2468
+ 540,
2469
+ 548,
2470
+ 554,
2471
+ 556,
2472
+ 563,
2473
+ 587,
2474
+ 601,
2475
+ 636,
2476
+ 989,
2477
+ 990,
2478
+ 993,
2479
+ 995,
2480
+ 1719,
2481
+ 1720,
2482
+ 1723,
2483
+ 2049,
2484
+ 3659,
2485
+ 4045,
2486
+ 4190,
2487
+ 5060,
2488
+ 5061,
2489
+ 6e3,
2490
+ 6566,
2491
+ 6665,
2492
+ 6666,
2493
+ 6667,
2494
+ 6668,
2495
+ 6669,
2496
+ 6679,
2497
+ 6697,
2498
+ 10080
2499
+ ]);
2500
+ async function listenAvoidingBlockedPorts(server, requestedPort, host) {
2501
+ const maxAttempts = requestedPort === 0 ? 5 : 1;
2502
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
2503
+ await new Promise((resolve, reject) => {
2504
+ const onError = (error) => {
2505
+ server.removeListener("listening", onListening);
2506
+ reject(error);
2507
+ };
2508
+ const onListening = () => {
2509
+ server.removeListener("error", onError);
2510
+ resolve();
2511
+ };
2512
+ server.once("error", onError);
2513
+ server.once("listening", onListening);
2514
+ server.listen(requestedPort, host);
2515
+ });
2516
+ const boundPort = getBoundPort(server);
2517
+ if (!FETCH_BLOCKED_PORTS.has(boundPort)) {
2518
+ return;
2519
+ }
2520
+ await new Promise((resolve) => server.close(() => resolve()));
2521
+ }
2522
+ }
2523
+
2524
+ export {
2525
+ formatResponse,
2526
+ buildAnswerPreview,
2527
+ buildHistoryEntry,
2528
+ buildHistoryBody,
2529
+ buildStoredHistoryEntry,
2530
+ registerTools,
2531
+ registerPrompts,
2532
+ registerResources,
2533
+ loadToolConfig,
2534
+ getEnabledTools,
2535
+ saveToolConfig,
2536
+ watchToolConfig,
2537
+ getPackageVersion,
2538
+ startDaemonServer,
2539
+ resolveRequestResource
2540
+ };