pplx-npx-search 0.3.1 → 0.3.3

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to pplx-cli will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.3] - 2026-05-26
9
+
10
+ ### Added
11
+ - **Perplexity Model Council handoff.** Added `pplx council` artifact workflows plus MCP tools for creating, reading, checking, and importing Model Council review runs.
12
+ - **Competitive-analysis template.** Added a `competitive-analysis` Perplexity Computer template that produces evidence briefs with a Council review prompt.
13
+
14
+ ## [0.3.2] - 2026-05-26
15
+
16
+ ### Added
17
+ - **MCP server.** Added `pplx-mcp`, a stdio MCP server for Claude Desktop, Claude Code, Codex, and other MCP clients.
18
+ - **MCP tool surface.** Exposes search, labs, auth status, model listing, and Perplexity Computer artifact tools through MCP.
19
+
8
20
  ## [0.3.1] - 2026-05-22
9
21
 
10
22
  ### Changed
@@ -67,6 +79,8 @@ First public release worth telling people about. (v0.2.0 was unpublished before
67
79
  - SSE streaming for real-time answers
68
80
  - Optional Playwright and Chrome CDP transports
69
81
 
82
+ [0.3.3]: https://github.com/thatsrajan/pplx-cli/compare/v0.3.2...v0.3.3
83
+ [0.3.2]: https://github.com/thatsrajan/pplx-cli/compare/v0.3.1...v0.3.2
70
84
  [0.3.1]: https://github.com/thatsrajan/pplx-cli/compare/v0.3.0...v0.3.1
71
85
  [0.3.0]: https://github.com/thatsrajan/pplx-cli/compare/v0.2.2...v0.3.0
72
86
  [0.2.2]: https://github.com/thatsrajan/pplx-cli/compare/v0.2.1...v0.2.2
package/README.md CHANGED
@@ -102,6 +102,7 @@ pplx reason "explain the Riemann hypothesis"
102
102
  pplx research "compare React vs Vue in 2026"
103
103
  pplx labs "hello world" # free, no auth needed
104
104
  pplx computer new "compare dinner options nearby"
105
+ pplx council new "critique the competitive threat from Acme in enterprise search"
105
106
  pplx models # list available models
106
107
  ```
107
108
 
@@ -152,6 +153,43 @@ pplx search "research this topic" --json --raw --mode pro
152
153
  }
153
154
  ```
154
155
 
156
+ ### MCP Server
157
+
158
+ `pplx-mcp` exposes the same Perplexity workflow through a local stdio MCP server for Claude Desktop, Claude Code, Codex, and other MCP clients.
159
+
160
+ ```bash
161
+ pplx-mcp
162
+ ```
163
+
164
+ Typical global client registrations:
165
+
166
+ ```bash
167
+ claude mcp add --scope user pplx -- /path/to/node /path/to/pplx-mcp
168
+ codex mcp add pplx -- /path/to/node /path/to/pplx-mcp
169
+ ```
170
+
171
+ For Claude Desktop, add the server under `mcpServers` in `~/Library/Application Support/Claude/claude_desktop_config.json`:
172
+
173
+ ```json
174
+ {
175
+ "mcpServers": {
176
+ "pplx": {
177
+ "command": "/path/to/node",
178
+ "args": ["/path/to/pplx-mcp"]
179
+ }
180
+ }
181
+ }
182
+ ```
183
+
184
+ The MCP server exposes:
185
+
186
+ - `pplx_search`: authenticated `search`, `reasoning`, and `deep-research` queries with sources and artifacts.
187
+ - `pplx_labs`: Labs model queries that do not require cookie auth.
188
+ - `pplx_auth_status`: validates the stored Perplexity browser cookies.
189
+ - `pplx_models`: lists known model aliases.
190
+ - `pplx_computer_create`, `pplx_computer_status`, `pplx_computer_read_task`, and `pplx_computer_import`: Perplexity Computer artifact handoff tools.
191
+ - `pplx_council_create`, `pplx_council_status`, `pplx_council_read_task`, and `pplx_council_import`: Perplexity Model Council artifact handoff tools.
192
+
155
193
  ---
156
194
 
157
195
  ## Artifacts
@@ -164,6 +202,7 @@ Query-producing commands save artifacts by default:
164
202
  - `pplx labs`
165
203
  - bare `pplx "query"`
166
204
  - `pplx computer new`
205
+ - `pplx council new`
167
206
 
168
207
  Each run gets a folder containing `meta.json`, `query.txt`, `answer.md`, `result.json`, and `sources.json`. Use `--out <dir>` to choose the destination for one run, or set `"artifactDir"` in `~/.config/pplx/config.json` to make it persistent. Use `--artifact-id <id>` when an agent needs a deterministic run folder, and `--no-artifact` to disable saving for one search-style run.
169
208
 
@@ -184,6 +223,18 @@ pplx computer import <run-id> --out ~/Dropbox/pplx-runs --json
184
223
 
185
224
  Computer runs include `task.md`, `result.schema.json`, and `computer-result.json`. Paste `task.md` into Perplexity Computer; when the task is done, place the structured result in `computer-result.json` so local agents can read it.
186
225
 
226
+ `pplx council` is an artifact handoff for Perplexity Model Council. It requires Model Council access in the Perplexity web UI and creates a task prompt plus result contract without claiming a private Council API:
227
+
228
+ ```bash
229
+ pplx council new "critique the competitive threat from Acme in enterprise search" --out ~/Dropbox/pplx-runs
230
+ pplx council new "review this evidence" --evidence ~/Dropbox/pplx-runs/acme/computer-result.json
231
+ pplx council open <run-id> --copy
232
+ pplx council status <run-id> --out ~/Dropbox/pplx-runs
233
+ pplx council import <run-id> --out ~/Dropbox/pplx-runs --json
234
+ ```
235
+
236
+ Council runs include `task.md`, `result.schema.json`, and `council-result.json`. Paste `task.md` into Perplexity Model Council; when the review is done, place the structured result in `council-result.json` so local agents can read it.
237
+
187
238
  ---
188
239
 
189
240
  ## Options
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { runMcpServer } from '../src/mcp-server.js';
3
+
4
+ runMcpServer().catch((error) => {
5
+ console.error('pplx MCP server error:', error?.message || error);
6
+ process.exit(1);
7
+ });
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "pplx-npx-search",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "CLI for Perplexity AI with cookie-based auth. Headless, agent-friendly, no API key required.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "pplx": "bin/pplx.js"
7
+ "pplx": "bin/pplx.js",
8
+ "pplx-mcp": "bin/pplx-mcp.js"
8
9
  },
9
10
  "scripts": {
10
11
  "start": "node bin/pplx.js",
@@ -37,12 +38,14 @@
37
38
  "node": ">=20.0.0"
38
39
  },
39
40
  "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.29.0",
40
42
  "better-sqlite3": "^12.10.0",
41
43
  "chalk": "^5.3.0",
42
44
  "commander": "^12.0.0",
43
45
  "eventsource-parser": "^3.0.0",
44
46
  "ora": "^8.0.0",
45
47
  "playwright": "^1.58.1",
46
- "ws": "^8.16.0"
48
+ "ws": "^8.21.0",
49
+ "zod": "^4.4.3"
47
50
  }
48
51
  }
package/src/cli.js CHANGED
@@ -15,6 +15,7 @@ import { loadConfig } from './config.js';
15
15
  import { resolveTimeoutMs } from './timeout.js';
16
16
  import { makeArtifactContext, resolveArtifactDir, writeStandardArtifact } from './artifacts.js';
17
17
  import {
18
+ COMPUTER_TEMPLATES,
18
19
  createComputerRun,
19
20
  copyTextToClipboard,
20
21
  importComputerResult,
@@ -22,6 +23,14 @@ import {
22
23
  openComputerUrl,
23
24
  readTaskFile,
24
25
  } from './computer.js';
26
+ import {
27
+ COUNCIL_TEMPLATES,
28
+ copyCouncilTaskToClipboard,
29
+ createCouncilRun,
30
+ importCouncilResult,
31
+ inspectCouncilRun,
32
+ openCouncilUrl,
33
+ } from './council.js';
25
34
 
26
35
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
27
36
 
@@ -562,7 +571,7 @@ const computer = program
562
571
  addArtifactOptions(computer
563
572
  .command('new [task]')
564
573
  .description('Create a Perplexity Computer task artifact')
565
- .option('--template <name>', 'Computer task template', 'compare')
574
+ .option('--template <name>', `Computer task template: ${COMPUTER_TEMPLATES.join(', ')}`, 'compare')
566
575
  .option('--json', 'Output run metadata as JSON'), { allowDisable: false })
567
576
  .action(async (taskArg, opts) => {
568
577
  opts = getOpts(opts);
@@ -649,6 +658,100 @@ addArtifactOptions(computer
649
658
  }
650
659
  });
651
660
 
661
+ // Council artifact handoff workflow
662
+ const council = program
663
+ .command('council')
664
+ .description('Create and manage Perplexity Model Council artifact handoffs');
665
+
666
+ addArtifactOptions(council
667
+ .command('new [task]')
668
+ .description('Create a Perplexity Model Council task artifact')
669
+ .option('--template <name>', `Council task template: ${COUNCIL_TEMPLATES.join(', ')}`, 'competitive-analysis')
670
+ .option('--evidence <path>', 'Optional local evidence artifact path to reference in the Council task')
671
+ .option('--json', 'Output run metadata as JSON'), { allowDisable: false })
672
+ .action(async (taskArg, opts) => {
673
+ opts = getOpts(opts);
674
+ const task = await resolveQuery(taskArg);
675
+ try {
676
+ const run = createCouncilRun({
677
+ task,
678
+ template: opts.template,
679
+ evidencePath: opts.evidence,
680
+ opts,
681
+ config: loadConfig(),
682
+ });
683
+ if (opts.json) {
684
+ console.log(JSON.stringify(run));
685
+ return;
686
+ }
687
+ console.log(chalk.green(`✓ Council task artifact created: ${run.artifactDir}`));
688
+ console.log(chalk.dim(` Task: ${run.taskPath}`));
689
+ console.log(chalk.dim(` Result: ${run.resultPath}`));
690
+ } catch (e) {
691
+ console.error(chalk.red(e.message));
692
+ process.exit(1);
693
+ }
694
+ });
695
+
696
+ addArtifactOptions(council
697
+ .command('open <run>')
698
+ .description('Open Perplexity and optionally copy task.md for Model Council')
699
+ .option('--copy', 'Copy task.md to the clipboard'), { allowDisable: false })
700
+ .action((runId, opts) => {
701
+ opts = getOpts(opts);
702
+ const runDir = resolveRunDir(runId, opts);
703
+ try {
704
+ if (opts.copy && !copyCouncilTaskToClipboard(runDir)) {
705
+ console.log(chalk.yellow('Clipboard copy is only supported on macOS.'));
706
+ }
707
+ if (!openCouncilUrl()) {
708
+ console.log(chalk.yellow('Opening Perplexity is only supported on macOS.'));
709
+ console.log('https://www.perplexity.ai/');
710
+ return;
711
+ }
712
+ console.log(chalk.green(`✓ Opened Perplexity for ${runDir}`));
713
+ if (opts.copy) console.log(chalk.dim(' Copied task.md to clipboard.'));
714
+ } catch (e) {
715
+ console.error(chalk.red(e.message));
716
+ process.exit(1);
717
+ }
718
+ });
719
+
720
+ addArtifactOptions(council
721
+ .command('status <run>')
722
+ .description('Inspect a Perplexity Model Council artifact run')
723
+ .option('--json', 'Output status as JSON'), { allowDisable: false })
724
+ .action((runId, opts) => {
725
+ opts = getOpts(opts);
726
+ const status = inspectCouncilRun(resolveRunDir(runId, opts));
727
+ if (opts.json) {
728
+ console.log(JSON.stringify(status));
729
+ return;
730
+ }
731
+ const label = status.status === 'complete'
732
+ ? chalk.green('[✓] Complete')
733
+ : status.status === 'pending'
734
+ ? chalk.yellow('[○] Pending')
735
+ : chalk.red(status.status === 'invalid' ? '[!] Invalid' : '[✗] Missing');
736
+ console.log(`${label} ${status.artifactDir}`);
737
+ if (status.reason) console.log(chalk.dim(` ${status.reason}`));
738
+ });
739
+
740
+ addArtifactOptions(council
741
+ .command('import <run>')
742
+ .description('Print a completed Perplexity Model Council result')
743
+ .option('--json', 'Output compact JSON'), { allowDisable: false })
744
+ .action((runId, opts) => {
745
+ opts = getOpts(opts);
746
+ try {
747
+ const result = importCouncilResult(resolveRunDir(runId, opts));
748
+ console.log(opts.json ? JSON.stringify(result) : JSON.stringify(result, null, 2));
749
+ } catch (e) {
750
+ console.error(chalk.red(e.message));
751
+ process.exit(1);
752
+ }
753
+ });
754
+
652
755
  // Models command
653
756
  program
654
757
  .command('models')
package/src/computer.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
 
11
11
  export const COMPUTER_URL = 'https://www.perplexity.ai/computer';
12
12
  export const COMPUTER_RESULT_FILE = 'computer-result.json';
13
+ export const COMPUTER_TEMPLATES = ['compare', 'competitive-analysis'];
13
14
  export const PENDING_COMPUTER_RESULT = {
14
15
  summary: '',
15
16
  winner: '',
@@ -18,6 +19,7 @@ export const PENDING_COMPUTER_RESULT = {
18
19
  sources: [],
19
20
  checked_at: '',
20
21
  notes: [],
22
+ council_review_prompt: '',
21
23
  _status: 'pending',
22
24
  };
23
25
 
@@ -34,14 +36,11 @@ const RESULT_SCHEMA = {
34
36
  sources: { type: 'array' },
35
37
  checked_at: { type: 'string' },
36
38
  notes: { type: 'array' },
39
+ council_review_prompt: { type: 'string' },
37
40
  },
38
41
  };
39
42
 
40
- export function buildComputerTask({ task, template = 'compare', resultPath }) {
41
- if (template !== 'compare') {
42
- throw new Error(`unsupported computer template: ${template}`);
43
- }
44
-
43
+ function buildCompareTask({ task, resultPath }) {
45
44
  return `# Perplexity Computer Task
46
45
 
47
46
  You are running a live comparison task for a local agent workflow.
@@ -72,6 +71,60 @@ If you cannot access the filesystem, return the JSON in the chat so the local ag
72
71
  `;
73
72
  }
74
73
 
74
+ function buildCompetitiveAnalysisTask({ task, resultPath }) {
75
+ return `# Perplexity Computer Task
76
+
77
+ You are running a live competitive-analysis task for a local agent workflow.
78
+
79
+ ## User request
80
+
81
+ ${task}
82
+
83
+ ## Instructions
84
+
85
+ - Treat this as "one competitor or competitor set" plus "one topic, market, product surface, capability, pricing motion, GTM motion, or customer segment."
86
+ - Use live web pages and direct source pages where possible, not only search snippets.
87
+ - Check competitor-owned pages first: home page, product docs, pricing, changelog, blog, help center, release notes, status pages, public roadmaps, job postings, and app marketplace listings.
88
+ - Add independent evidence where useful: customer reviews, forum threads, analyst commentary, news, benchmark posts, partner pages, search ads, and social posts.
89
+ - Preserve source URLs for every material claim.
90
+ - Separate facts from interpretation.
91
+ - Identify:
92
+ - current positioning
93
+ - recent changes or launches
94
+ - pricing and packaging implications
95
+ - GTM and distribution signals
96
+ - customer pain or praise signals
97
+ - likely strategic intent
98
+ - threat level to the user request's context
99
+ - evidence gaps that need follow-up
100
+ - Include the time the information was checked.
101
+ - Mark uncertainty explicitly. Do not invent missing values.
102
+ - Prefer structured evidence over prose.
103
+ - Include \`council_review_prompt\`: a concise prompt that can be pasted into Perplexity Model Council to critique the evidence and strategy read.
104
+
105
+ ## Output target
106
+
107
+ Write the final result as JSON matching \`result.schema.json\`.
108
+
109
+ If you can access the local filesystem, save it here:
110
+
111
+ \`${resultPath}\`
112
+
113
+ If you cannot access the filesystem, return the JSON in the chat so the local agent or user can place it in that file.
114
+ `;
115
+ }
116
+
117
+ export function buildComputerTask({ task, template = 'compare', resultPath }) {
118
+ if (!COMPUTER_TEMPLATES.includes(template)) {
119
+ throw new Error(`unsupported computer template: ${template}`);
120
+ }
121
+
122
+ if (template === 'competitive-analysis') {
123
+ return buildCompetitiveAnalysisTask({ task, resultPath });
124
+ }
125
+ return buildCompareTask({ task, resultPath });
126
+ }
127
+
75
128
  export function createComputerRun({ task, template = 'compare', opts = {}, config = {} }) {
76
129
  const ctx = makeArtifactContext({ command: 'computer', query: task, opts, config });
77
130
  if (!ctx) {
package/src/council.js ADDED
@@ -0,0 +1,282 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import {
5
+ ARTIFACT_SCHEMA_VERSION,
6
+ makeArtifactContext,
7
+ readJsonFile,
8
+ writeJson,
9
+ } from './artifacts.js';
10
+ import { copyTextToClipboard } from './computer.js';
11
+
12
+ export const COUNCIL_URL = 'https://www.perplexity.ai/';
13
+ export const COUNCIL_RESULT_FILE = 'council-result.json';
14
+ export const COUNCIL_TEMPLATES = ['competitive-analysis', 'strategy-review'];
15
+ export const PENDING_COUNCIL_RESULT = {
16
+ summary: '',
17
+ consensus: [],
18
+ disagreements: [],
19
+ risks: [],
20
+ recommendations: [],
21
+ followups: [],
22
+ sources: [],
23
+ confidence: 'low',
24
+ checked_at: '',
25
+ notes: [],
26
+ _status: 'pending',
27
+ };
28
+
29
+ const RESULT_SCHEMA = {
30
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
31
+ title: 'pplx council review result',
32
+ type: 'object',
33
+ required: [
34
+ 'summary',
35
+ 'consensus',
36
+ 'disagreements',
37
+ 'risks',
38
+ 'recommendations',
39
+ 'followups',
40
+ 'sources',
41
+ 'confidence',
42
+ 'checked_at',
43
+ 'notes',
44
+ ],
45
+ properties: {
46
+ summary: { type: 'string' },
47
+ consensus: { type: 'array' },
48
+ disagreements: { type: 'array' },
49
+ risks: { type: 'array' },
50
+ recommendations: { type: 'array' },
51
+ followups: { type: 'array' },
52
+ sources: { type: 'array' },
53
+ confidence: { enum: ['low', 'medium', 'high'] },
54
+ checked_at: { type: 'string' },
55
+ notes: { type: 'array' },
56
+ },
57
+ };
58
+
59
+ function buildCompetitiveAnalysisCouncilTask({ task, evidencePath, resultPath }) {
60
+ const evidenceBlock = evidencePath
61
+ ? `\n## Evidence brief\n\nUse this local evidence artifact if available:\n\n\`${evidencePath}\`\n`
62
+ : '';
63
+
64
+ return `# Perplexity Model Council Task
65
+
66
+ Use Perplexity Model Council for a multi-model review. This is a judgment and synthesis pass, not the primary web-browsing pass.
67
+
68
+ ## User request
69
+
70
+ ${task}
71
+ ${evidenceBlock}
72
+ ## Instructions
73
+
74
+ - Ask the Council to evaluate the same evidence from multiple model perspectives before synthesizing.
75
+ - Focus on the competitor, topic, market, product capability, pricing motion, GTM motion, or customer segment named in the request.
76
+ - Separate source-backed facts from strategic interpretation.
77
+ - Identify where the models agree and where they disagree.
78
+ - Challenge the strongest assumption in the analysis.
79
+ - Surface missing evidence that would change the conclusion.
80
+ - Produce concrete recommendations and monitoring follow-ups.
81
+ - Preserve source URLs from the evidence brief and add any new source URLs used by Council.
82
+ - Mark uncertainty explicitly. Do not invent missing values.
83
+
84
+ ## Output target
85
+
86
+ Return the final result as JSON matching \`result.schema.json\`.
87
+
88
+ If you can access the local filesystem, save it here:
89
+
90
+ \`${resultPath}\`
91
+
92
+ If you cannot access the filesystem, return the JSON in the chat so the local agent or user can place it in that file.
93
+ `;
94
+ }
95
+
96
+ function buildStrategyReviewCouncilTask({ task, evidencePath, resultPath }) {
97
+ const evidenceBlock = evidencePath
98
+ ? `\n## Evidence brief\n\nUse this local evidence artifact if available:\n\n\`${evidencePath}\`\n`
99
+ : '';
100
+
101
+ return `# Perplexity Model Council Task
102
+
103
+ Use Perplexity Model Council for a multi-model strategy review.
104
+
105
+ ## User request
106
+
107
+ ${task}
108
+ ${evidenceBlock}
109
+ ## Instructions
110
+
111
+ - Ask the Council to evaluate the request from multiple reasoning perspectives before synthesizing.
112
+ - Identify consensus, disagreements, risks, blind spots, and decision criteria.
113
+ - Separate source-backed facts from interpretation.
114
+ - Challenge the strongest assumption.
115
+ - Surface missing evidence that would change the conclusion.
116
+ - Produce concrete recommendations and follow-up questions.
117
+ - Preserve source URLs from the evidence brief and add any new source URLs used by Council.
118
+ - Mark uncertainty explicitly. Do not invent missing values.
119
+
120
+ ## Output target
121
+
122
+ Return the final result as JSON matching \`result.schema.json\`.
123
+
124
+ If you can access the local filesystem, save it here:
125
+
126
+ \`${resultPath}\`
127
+
128
+ If you cannot access the filesystem, return the JSON in the chat so the local agent or user can place it in that file.
129
+ `;
130
+ }
131
+
132
+ export function buildCouncilTask({
133
+ task,
134
+ template = 'competitive-analysis',
135
+ evidencePath,
136
+ resultPath,
137
+ }) {
138
+ if (!COUNCIL_TEMPLATES.includes(template)) {
139
+ throw new Error(`unsupported council template: ${template}`);
140
+ }
141
+
142
+ if (template === 'strategy-review') {
143
+ return buildStrategyReviewCouncilTask({ task, evidencePath, resultPath });
144
+ }
145
+ return buildCompetitiveAnalysisCouncilTask({ task, evidencePath, resultPath });
146
+ }
147
+
148
+ export function createCouncilRun({
149
+ task,
150
+ template = 'competitive-analysis',
151
+ evidencePath,
152
+ opts = {},
153
+ config = {},
154
+ }) {
155
+ const ctx = makeArtifactContext({ command: 'council', query: task, opts, config });
156
+ if (!ctx) {
157
+ throw new Error('council runs require artifacts; omit --no-artifact for this command');
158
+ }
159
+
160
+ mkdirSync(ctx.artifactDir, { recursive: true });
161
+ const createdAt = new Date().toISOString();
162
+ const resultPath = join(ctx.artifactDir, COUNCIL_RESULT_FILE);
163
+ const taskText = buildCouncilTask({ task, template, evidencePath, resultPath });
164
+ const meta = {
165
+ schemaVersion: ARTIFACT_SCHEMA_VERSION,
166
+ command: 'council',
167
+ query: task,
168
+ template,
169
+ evidencePath: evidencePath || null,
170
+ artifactId: ctx.artifactId,
171
+ artifactDir: ctx.artifactDir,
172
+ createdAt,
173
+ status: 'pending',
174
+ };
175
+ const result = {
176
+ query: task,
177
+ answer: '',
178
+ sources: [],
179
+ command: 'council',
180
+ mode: 'council',
181
+ model: 'model-council',
182
+ template,
183
+ evidencePath: evidencePath || null,
184
+ artifactId: ctx.artifactId,
185
+ artifactDir: ctx.artifactDir,
186
+ createdAt,
187
+ status: 'pending',
188
+ councilResultFile: COUNCIL_RESULT_FILE,
189
+ };
190
+
191
+ writeJson(join(ctx.artifactDir, 'meta.json'), meta);
192
+ writeFileSync(join(ctx.artifactDir, 'query.txt'), `${task}\n`, 'utf8');
193
+ writeFileSync(join(ctx.artifactDir, 'answer.md'), taskText, 'utf8');
194
+ writeJson(join(ctx.artifactDir, 'result.json'), result);
195
+ writeJson(join(ctx.artifactDir, 'sources.json'), []);
196
+ writeFileSync(join(ctx.artifactDir, 'task.md'), taskText, 'utf8');
197
+ writeJson(join(ctx.artifactDir, 'result.schema.json'), RESULT_SCHEMA);
198
+ writeJson(join(ctx.artifactDir, COUNCIL_RESULT_FILE), PENDING_COUNCIL_RESULT);
199
+
200
+ return {
201
+ artifactId: ctx.artifactId,
202
+ artifactDir: ctx.artifactDir,
203
+ taskPath: join(ctx.artifactDir, 'task.md'),
204
+ resultPath,
205
+ };
206
+ }
207
+
208
+ function assertCouncilResult(value) {
209
+ const missing = [
210
+ 'summary',
211
+ 'consensus',
212
+ 'disagreements',
213
+ 'risks',
214
+ 'recommendations',
215
+ 'followups',
216
+ 'sources',
217
+ 'confidence',
218
+ 'checked_at',
219
+ 'notes',
220
+ ].filter((key) => !(key in value));
221
+ if (missing.length) return { ok: false, reason: `missing fields: ${missing.join(', ')}` };
222
+ if (!['low', 'medium', 'high'].includes(value.confidence)) {
223
+ return { ok: false, reason: 'confidence must be low, medium, or high' };
224
+ }
225
+ for (const key of ['consensus', 'disagreements', 'risks', 'recommendations', 'followups', 'sources', 'notes']) {
226
+ if (!Array.isArray(value[key])) return { ok: false, reason: `${key} must be an array` };
227
+ }
228
+ return { ok: true, reason: null };
229
+ }
230
+
231
+ export function inspectCouncilRun(runDir) {
232
+ const artifactDir = resolve(runDir);
233
+ const resultPath = join(artifactDir, COUNCIL_RESULT_FILE);
234
+ const metaPath = join(artifactDir, 'meta.json');
235
+ if (!existsSync(metaPath)) {
236
+ return { status: 'missing', artifactDir, resultPath, reason: 'meta.json not found' };
237
+ }
238
+ if (!existsSync(resultPath)) {
239
+ return { status: 'pending', artifactDir, resultPath, reason: `${COUNCIL_RESULT_FILE} not found` };
240
+ }
241
+ try {
242
+ const result = readJsonFile(resultPath);
243
+ if (result._status === 'pending') {
244
+ return { status: 'pending', artifactDir, resultPath, reason: `${COUNCIL_RESULT_FILE} is still pending` };
245
+ }
246
+ const validation = assertCouncilResult(result);
247
+ return {
248
+ status: validation.ok ? 'complete' : 'invalid',
249
+ artifactDir,
250
+ resultPath,
251
+ reason: validation.reason,
252
+ result,
253
+ };
254
+ } catch (e) {
255
+ return { status: 'invalid', artifactDir, resultPath, reason: e.message };
256
+ }
257
+ }
258
+
259
+ export function importCouncilResult(runDir) {
260
+ const status = inspectCouncilRun(runDir);
261
+ if (status.status !== 'complete') {
262
+ throw new Error(`council result is ${status.status}: ${status.reason}`);
263
+ }
264
+ return status.result;
265
+ }
266
+
267
+ export function openCouncilUrl() {
268
+ if (process.platform === 'darwin') {
269
+ execFileSync('open', [COUNCIL_URL]);
270
+ return true;
271
+ }
272
+ return false;
273
+ }
274
+
275
+ export function copyCouncilTaskToClipboard(runDir) {
276
+ const taskText = readCouncilTaskFile(runDir);
277
+ return copyTextToClipboard(taskText);
278
+ }
279
+
280
+ export function readCouncilTaskFile(runDir) {
281
+ return readFileSync(join(resolve(runDir), 'task.md'), 'utf8');
282
+ }
@@ -0,0 +1,437 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join, resolve, isAbsolute } from 'node:path';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import * as z from 'zod/v4';
6
+ import { loadConfig } from './config.js';
7
+ import { loadCookies } from './cookies.js';
8
+ import { testAuth } from './session.js';
9
+ import { search } from './search.js';
10
+ import { LabsClient } from './labs.js';
11
+ import { resolveTimeoutMs } from './timeout.js';
12
+ import { makeArtifactContext, resolveArtifactDir, writeStandardArtifact } from './artifacts.js';
13
+ import {
14
+ COMPUTER_TEMPLATES,
15
+ createComputerRun,
16
+ importComputerResult,
17
+ inspectComputerRun,
18
+ readTaskFile,
19
+ } from './computer.js';
20
+ import {
21
+ COUNCIL_TEMPLATES,
22
+ createCouncilRun,
23
+ importCouncilResult,
24
+ inspectCouncilRun,
25
+ readCouncilTaskFile,
26
+ } from './council.js';
27
+ import { MODEL_MAP, LABS_MODELS } from './constants.js';
28
+
29
+ const SERVER_NAME = 'pplx';
30
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
31
+ const SERVER_VERSION = pkg.version;
32
+ const SEARCH_MODES = ['auto', 'pro', 'reasoning', 'deep-research'];
33
+ const TRANSPORTS = ['auto', 'http', 'playwright', 'curl', 'chrome'];
34
+
35
+ const optionalArtifactArgs = {
36
+ out: z.string().optional().describe('Artifact root for this run. Defaults to ~/.config/pplx/config.json artifactDir.'),
37
+ artifactId: z.string().optional().describe('Deterministic artifact id for this run.'),
38
+ saveArtifact: z.boolean().optional().default(true).describe('Save the standard pplx artifact files.'),
39
+ };
40
+
41
+ function toTextResult(value) {
42
+ return {
43
+ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
44
+ structuredContent: value,
45
+ };
46
+ }
47
+
48
+ function normalizeSources(sources) {
49
+ if (!sources) return ['web'];
50
+ if (Array.isArray(sources)) return sources;
51
+ return String(sources).split(',').map((source) => source.trim()).filter(Boolean);
52
+ }
53
+
54
+ function normalizeSourceResult(result) {
55
+ return {
56
+ title: result.name || result.title || '',
57
+ url: result.url || '',
58
+ };
59
+ }
60
+
61
+ function mcpArtifactOpts(args = {}) {
62
+ if (args.saveArtifact === false) return { artifact: false };
63
+ return {
64
+ out: args.out,
65
+ artifactId: args.artifactId,
66
+ };
67
+ }
68
+
69
+ function transportOpts(transport, config) {
70
+ if (transport === 'chrome') return { chrome: true };
71
+ if (transport === 'playwright') return { playwright: true };
72
+ if (transport === 'curl') return { curl: true };
73
+ if (transport === 'http') return { playwright: false, chrome: false, curl: false };
74
+ return {
75
+ chrome: config.chrome,
76
+ playwright: config.playwright,
77
+ curl: config.curl,
78
+ };
79
+ }
80
+
81
+ async function assertAuthReady({ cookies, opts }) {
82
+ if (opts.chrome || opts.allowAnonymous) return;
83
+ if (Object.keys(cookies).length === 0) {
84
+ throw new Error('No Perplexity cookies stored. Run: pplx auth --browser auto');
85
+ }
86
+ const ok = await testAuth(cookies);
87
+ if (!ok) {
88
+ throw new Error('Stored Perplexity cookies are invalid or expired. Run: pplx auth --browser auto');
89
+ }
90
+ }
91
+
92
+ export async function runSearchTool(args) {
93
+ const config = loadConfig();
94
+ const mode = args.mode || 'pro';
95
+ const transport = args.transport || 'auto';
96
+ const opts = {
97
+ ...config,
98
+ ...transportOpts(transport, config),
99
+ mode,
100
+ model: args.model,
101
+ sources: normalizeSources(args.sources),
102
+ language: args.language || args.lang || 'en-US',
103
+ incognito: args.incognito ?? false,
104
+ allowAnonymous: args.allowAnonymous ?? false,
105
+ timeoutMs: undefined,
106
+ };
107
+ opts.timeoutMs = resolveTimeoutMs({ ...opts, timeoutMs: args.timeoutMs, mode });
108
+
109
+ const cookies = loadCookies() || {};
110
+ await assertAuthReady({ cookies, opts });
111
+
112
+ const artifactCtx = makeArtifactContext({
113
+ command: mode === 'deep-research' ? 'research' : mode === 'reasoning' ? 'reason' : 'search',
114
+ query: args.query,
115
+ opts: mcpArtifactOpts(args),
116
+ config,
117
+ });
118
+
119
+ let lastAnswer = '';
120
+ let lastData = null;
121
+ for await (const data of search(args.query, cookies, opts)) {
122
+ lastData = data;
123
+ if ((data.answer || '').length >= lastAnswer.length) {
124
+ lastAnswer = data.answer || lastAnswer;
125
+ }
126
+ }
127
+
128
+ const answer = lastData?.answer || lastAnswer || '';
129
+ if (!answer) throw new Error('No answer received from Perplexity.');
130
+
131
+ const sources = (lastData?.web_results || []).map(normalizeSourceResult);
132
+ const artifactInfo = writeStandardArtifact(artifactCtx, {
133
+ answer,
134
+ sources,
135
+ mode,
136
+ model: args.model || 'default',
137
+ });
138
+
139
+ return {
140
+ query: args.query,
141
+ answer,
142
+ sources,
143
+ mode,
144
+ model: args.model || 'default',
145
+ artifactDir: artifactInfo?.artifactDir || null,
146
+ artifactId: artifactInfo?.artifactId || null,
147
+ };
148
+ }
149
+
150
+ export async function runLabsTool(args) {
151
+ const config = loadConfig();
152
+ const model = args.model || 'sonar';
153
+ const artifactCtx = makeArtifactContext({
154
+ command: 'labs',
155
+ query: args.query,
156
+ opts: mcpArtifactOpts(args),
157
+ config,
158
+ });
159
+ const client = new LabsClient();
160
+ let answer = '';
161
+ const events = [];
162
+ try {
163
+ await client.connect();
164
+ for await (const data of client.ask(args.query, model)) {
165
+ events.push(data);
166
+ answer = data.output || answer;
167
+ }
168
+ } finally {
169
+ client.close();
170
+ }
171
+
172
+ const artifactInfo = writeStandardArtifact(artifactCtx, {
173
+ answer,
174
+ sources: [],
175
+ mode: 'labs',
176
+ model,
177
+ });
178
+
179
+ return {
180
+ query: args.query,
181
+ answer,
182
+ events,
183
+ mode: 'labs',
184
+ model,
185
+ artifactDir: artifactInfo?.artifactDir || null,
186
+ artifactId: artifactInfo?.artifactId || null,
187
+ };
188
+ }
189
+
190
+ export async function getAuthStatus() {
191
+ const cookies = loadCookies() || {};
192
+ const cookieCount = Object.keys(cookies).length;
193
+ if (cookieCount === 0) {
194
+ return {
195
+ authenticated: false,
196
+ cookieCount,
197
+ message: 'No cookies stored. Run: pplx auth --browser auto',
198
+ };
199
+ }
200
+ const authenticated = await testAuth(cookies);
201
+ return {
202
+ authenticated,
203
+ cookieCount,
204
+ message: authenticated
205
+ ? 'Stored Perplexity cookies are valid.'
206
+ : 'Stored Perplexity cookies are invalid or expired. Run: pplx auth --browser auto',
207
+ };
208
+ }
209
+
210
+ function resolveRunDir(run, out, config) {
211
+ if (isAbsolute(run) || run.includes('/')) return resolve(run);
212
+ return join(resolveArtifactDir({ out, config }), run);
213
+ }
214
+
215
+ export function createPplxMcpServer() {
216
+ const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
217
+
218
+ server.registerTool('pplx_search', {
219
+ title: 'Perplexity Search',
220
+ description: 'Run an authenticated Perplexity search/reasoning/deep-research query and return answer, sources, and artifact paths.',
221
+ inputSchema: {
222
+ query: z.string().min(1).describe('Question or research prompt.'),
223
+ mode: z.enum(SEARCH_MODES).optional().default('pro').describe('Perplexity mode.'),
224
+ model: z.string().optional().describe('Optional model alias or raw Perplexity model id.'),
225
+ sources: z.array(z.string()).optional().default(['web']).describe('Source types such as web, scholar, or social.'),
226
+ language: z.string().optional().default('en-US').describe('Language code.'),
227
+ incognito: z.boolean().optional().default(false).describe('Do not save the query to Perplexity history.'),
228
+ transport: z.enum(TRANSPORTS).optional().default('auto').describe('Transport override for Perplexity calls.'),
229
+ timeoutMs: z.union([z.string(), z.number()]).optional().describe('Overall stream timeout, e.g. 120s, 10m, or milliseconds.'),
230
+ allowAnonymous: z.boolean().optional().default(false).describe('Allow anonymous Perplexity responses when cookies are missing or expired.'),
231
+ ...optionalArtifactArgs,
232
+ },
233
+ annotations: {
234
+ title: 'Perplexity Search',
235
+ readOnlyHint: false,
236
+ openWorldHint: true,
237
+ },
238
+ }, async (args) => toTextResult(await runSearchTool(args)));
239
+
240
+ server.registerTool('pplx_labs', {
241
+ title: 'Perplexity Labs',
242
+ description: 'Query Perplexity Labs models without browser-cookie auth.',
243
+ inputSchema: {
244
+ query: z.string().min(1).describe('Question or prompt.'),
245
+ model: z.enum(LABS_MODELS).optional().default('sonar').describe('Labs model.'),
246
+ ...optionalArtifactArgs,
247
+ },
248
+ annotations: {
249
+ title: 'Perplexity Labs',
250
+ readOnlyHint: false,
251
+ openWorldHint: true,
252
+ },
253
+ }, async (args) => toTextResult(await runLabsTool(args)));
254
+
255
+ server.registerTool('pplx_auth_status', {
256
+ title: 'Perplexity Auth Status',
257
+ description: 'Check whether stored Perplexity cookies are present and authenticated.',
258
+ inputSchema: {},
259
+ annotations: {
260
+ title: 'Perplexity Auth Status',
261
+ readOnlyHint: true,
262
+ openWorldHint: false,
263
+ },
264
+ }, async () => toTextResult(await getAuthStatus()));
265
+
266
+ server.registerTool('pplx_models', {
267
+ title: 'Perplexity Models',
268
+ description: 'List known Perplexity model aliases exposed by pplx-cli.',
269
+ inputSchema: {},
270
+ annotations: {
271
+ title: 'Perplexity Models',
272
+ readOnlyHint: true,
273
+ openWorldHint: false,
274
+ },
275
+ }, async () => toTextResult({ modes: MODEL_MAP, labs: LABS_MODELS }));
276
+
277
+ server.registerTool('pplx_computer_create', {
278
+ title: 'Create Perplexity Computer Handoff',
279
+ description: 'Create a Perplexity Computer artifact handoff folder containing task.md, result.schema.json, and computer-result.json.',
280
+ inputSchema: {
281
+ task: z.string().min(1).describe('Live web task for Perplexity Computer.'),
282
+ template: z.enum(COMPUTER_TEMPLATES).optional().default('compare').describe('Computer task template.'),
283
+ out: z.string().optional().describe('Artifact root for this run.'),
284
+ artifactId: z.string().optional().describe('Deterministic artifact id for this run.'),
285
+ },
286
+ annotations: {
287
+ title: 'Create Perplexity Computer Handoff',
288
+ readOnlyHint: false,
289
+ openWorldHint: false,
290
+ },
291
+ }, async (args) => {
292
+ const run = createComputerRun({
293
+ task: args.task,
294
+ template: args.template || 'compare',
295
+ opts: { out: args.out, artifactId: args.artifactId },
296
+ config: loadConfig(),
297
+ });
298
+ return toTextResult(run);
299
+ });
300
+
301
+ server.registerTool('pplx_council_create', {
302
+ title: 'Create Perplexity Model Council Handoff',
303
+ description: 'Create a Perplexity Model Council artifact handoff folder containing task.md, result.schema.json, and council-result.json.',
304
+ inputSchema: {
305
+ task: z.string().min(1).describe('Judgment, strategy, or competitive-analysis task for Model Council.'),
306
+ template: z.enum(COUNCIL_TEMPLATES).optional().default('competitive-analysis').describe('Council task template.'),
307
+ evidencePath: z.string().optional().describe('Optional local evidence artifact path to reference in the Council task.'),
308
+ out: z.string().optional().describe('Artifact root for this run.'),
309
+ artifactId: z.string().optional().describe('Deterministic artifact id for this run.'),
310
+ },
311
+ annotations: {
312
+ title: 'Create Perplexity Model Council Handoff',
313
+ readOnlyHint: false,
314
+ openWorldHint: false,
315
+ },
316
+ }, async (args) => {
317
+ const run = createCouncilRun({
318
+ task: args.task,
319
+ template: args.template || 'competitive-analysis',
320
+ evidencePath: args.evidencePath,
321
+ opts: { out: args.out, artifactId: args.artifactId },
322
+ config: loadConfig(),
323
+ });
324
+ return toTextResult(run);
325
+ });
326
+
327
+ server.registerTool('pplx_council_status', {
328
+ title: 'Perplexity Model Council Status',
329
+ description: 'Inspect a Perplexity Model Council artifact run.',
330
+ inputSchema: {
331
+ run: z.string().min(1).describe('Run id or absolute run folder path.'),
332
+ out: z.string().optional().describe('Artifact root used when run is an id.'),
333
+ },
334
+ annotations: {
335
+ title: 'Perplexity Model Council Status',
336
+ readOnlyHint: true,
337
+ openWorldHint: false,
338
+ },
339
+ }, async (args) => {
340
+ const runDir = resolveRunDir(args.run, args.out, loadConfig());
341
+ return toTextResult(inspectCouncilRun(runDir));
342
+ });
343
+
344
+ server.registerTool('pplx_council_import', {
345
+ title: 'Import Perplexity Model Council Result',
346
+ description: 'Read and validate a completed council-result.json from a Perplexity Model Council artifact run.',
347
+ inputSchema: {
348
+ run: z.string().min(1).describe('Run id or absolute run folder path.'),
349
+ out: z.string().optional().describe('Artifact root used when run is an id.'),
350
+ },
351
+ annotations: {
352
+ title: 'Import Perplexity Model Council Result',
353
+ readOnlyHint: true,
354
+ openWorldHint: false,
355
+ },
356
+ }, async (args) => {
357
+ const runDir = resolveRunDir(args.run, args.out, loadConfig());
358
+ return toTextResult(importCouncilResult(runDir));
359
+ });
360
+
361
+ server.registerTool('pplx_council_read_task', {
362
+ title: 'Read Perplexity Model Council Task',
363
+ description: 'Read the task.md prompt from a Perplexity Model Council artifact run.',
364
+ inputSchema: {
365
+ run: z.string().min(1).describe('Run id or absolute run folder path.'),
366
+ out: z.string().optional().describe('Artifact root used when run is an id.'),
367
+ },
368
+ annotations: {
369
+ title: 'Read Perplexity Model Council Task',
370
+ readOnlyHint: true,
371
+ openWorldHint: false,
372
+ },
373
+ }, async (args) => {
374
+ const runDir = resolveRunDir(args.run, args.out, loadConfig());
375
+ return toTextResult({ runDir, task: readCouncilTaskFile(runDir) });
376
+ });
377
+
378
+ server.registerTool('pplx_computer_status', {
379
+ title: 'Perplexity Computer Status',
380
+ description: 'Inspect a Perplexity Computer artifact run.',
381
+ inputSchema: {
382
+ run: z.string().min(1).describe('Run id or absolute run folder path.'),
383
+ out: z.string().optional().describe('Artifact root used when run is an id.'),
384
+ },
385
+ annotations: {
386
+ title: 'Perplexity Computer Status',
387
+ readOnlyHint: true,
388
+ openWorldHint: false,
389
+ },
390
+ }, async (args) => {
391
+ const runDir = resolveRunDir(args.run, args.out, loadConfig());
392
+ return toTextResult(inspectComputerRun(runDir));
393
+ });
394
+
395
+ server.registerTool('pplx_computer_import', {
396
+ title: 'Import Perplexity Computer Result',
397
+ description: 'Read and validate a completed computer-result.json from a Perplexity Computer artifact run.',
398
+ inputSchema: {
399
+ run: z.string().min(1).describe('Run id or absolute run folder path.'),
400
+ out: z.string().optional().describe('Artifact root used when run is an id.'),
401
+ },
402
+ annotations: {
403
+ title: 'Import Perplexity Computer Result',
404
+ readOnlyHint: true,
405
+ openWorldHint: false,
406
+ },
407
+ }, async (args) => {
408
+ const runDir = resolveRunDir(args.run, args.out, loadConfig());
409
+ return toTextResult(importComputerResult(runDir));
410
+ });
411
+
412
+ server.registerTool('pplx_computer_read_task', {
413
+ title: 'Read Perplexity Computer Task',
414
+ description: 'Read the task.md prompt from a Perplexity Computer artifact run.',
415
+ inputSchema: {
416
+ run: z.string().min(1).describe('Run id or absolute run folder path.'),
417
+ out: z.string().optional().describe('Artifact root used when run is an id.'),
418
+ },
419
+ annotations: {
420
+ title: 'Read Perplexity Computer Task',
421
+ readOnlyHint: true,
422
+ openWorldHint: false,
423
+ },
424
+ }, async (args) => {
425
+ const runDir = resolveRunDir(args.run, args.out, loadConfig());
426
+ return toTextResult({ runDir, task: readTaskFile(runDir) });
427
+ });
428
+
429
+ return server;
430
+ }
431
+
432
+ export async function runMcpServer() {
433
+ const server = createPplxMcpServer();
434
+ const transport = new StdioServerTransport();
435
+ await server.connect(transport);
436
+ console.error('pplx MCP server running on stdio');
437
+ }