Skip to content

Commit 783206a

Browse files
A.R.claude
andcommitted
feat(prompts): user-defined MCP prompts (custom commands) + Prompts dashboard tab
Add a "Prompts" tab to the dashboard for creating/editing/deleting custom MCP slash-command prompts (and overriding/resetting the built-ins), persisted to ~/.perplexity-mcp/prompts.json and served via the standard MCP prompts capability so they appear as /slash-commands in every connected client. - shared: PromptArg/PromptDef/PromptsState types; prompts:save/delete/reset + prompts:state message contracts; DashboardState.prompts. - mcp-server: new prompts-config.ts (built-ins + read/write/merge/mutation + {arg} substitution), exported via the perplexity-user-mcp/prompts-config subpath; registerPrompts() now loads built-ins+overrides+custom dynamically. - extension: DashboardProvider handles the messages via an extracted, tested prompts-handler.ts; buildState() includes the merged prompt list. - webview: Prompts tab (PromptsTab.tsx) + store slice + action types + styles. Ported from the sibling Airtable extension's Prompts feature; built-in defs + logic live once in the mcp-server (single source of truth). The embedded daemon refreshes per request; long-lived stdio clients reconnect to pick up edits. Tests: prompts-config (mcp-server) + prompts-handler (extension); full build/typecheck clean across all four packages, 1218 tests pass. Version bump + VSIX smoke deferred to the release gate (repo 0.8.51 trails published npm 0.8.52 — needs maintainer reconciliation before tagging). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ca3ba60 commit 783206a

19 files changed

Lines changed: 1325 additions & 45 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ All notable changes to this project are documented here. Format follows
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- **Prompts tab — build custom MCP slash-commands from the dashboard** ([`PromptsTab.tsx`](packages/webview/src/components/PromptsTab.tsx), [`prompts-config.ts`](packages/mcp-server/src/prompts-config.ts), [`prompts.ts`](packages/mcp-server/src/prompts.ts), [`prompts-handler.ts`](packages/extension/src/webview/prompts-handler.ts)). A new **Prompts** tab lets you create, edit, delete, and reset reusable prompt templates (`name` + `description` + `arguments` + a `{arg}`-substituted `template`). They persist to `~/.perplexity-mcp/prompts.json` (`{ overrides, custom }`, atomic write via `safeAtomicWriteFileSync`) and are served through the standard MCP prompts capability — so they appear as `/slash-commands` in every connected client (Claude Desktop, Cursor, …) with **no** per-IDE config writing. Ships five built-ins (`perplexity.researchPlan`, `perplexity.reasoningPlan`, `perplexity-fact-check`, `perplexity-compare`, `perplexity-latest`); built-ins can be overridden and reset to default, custom prompts can be deleted. Built-in defs + read/write/merge/render live once in the mcp-server (`prompts-config`, exported via the new `perplexity-user-mcp/prompts-config` subpath) and are imported by the extension — single source of truth. The embedded daemon rebuilds its MCP server per request, so edits apply on the next connection automatically; long-lived stdio servers need a client reconnect (an in-UI notice explains how). Ported from the sibling Airtable extension's Prompts feature.
12+
13+
### Verification
14+
15+
- New unit tests: [`test/prompts-config.test.js`](packages/mcp-server/test/prompts-config.test.js) + [`test/prompts.test.js`](packages/mcp-server/test/prompts.test.js) (mcp-server — `{arg}` substitution, merge/override flags, upsert/delete/reset, read-write round-trip with a temp `PERPLEXITY_CONFIG_DIR`, dynamic registration against a mock `McpServer`) and [`tests/prompts-handler.test.ts`](packages/extension/tests/prompts-handler.test.ts) (extension — save/delete/reset validation + dispatch). Full build + typecheck clean across all four packages; **1218 tests pass**. **Still needs the manual VSIX smoke** before a release is tagged — the live dashboard render of the Prompts tab and the `/slash-command`-appears-in-a-client round-trip can't be exercised headlessly.
16+
917
## [0.8.51] — 2026-06-04 — "Reconnecting…" spinner during daemon reinit
1018

1119
> Follow-up UX for the 0.8.49 passphrase fix. After a profile switch or "Refresh state" the extension re-supplies the vault passphrase and hot-reloads the daemon — a headed reinit that takes ~30s. The daemon badge kept showing the stale "anonymous" message during that window, so it looked stuck even though it was working (confirmed in the field: the daemon goes green ~30s after a switch).

packages/extension/src/webview/DashboardProvider.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import {
2727
getRulesStatuses
2828
} from "../auto-config/index.js";
2929
import type { IdeTarget } from "@perplexity-user-mcp/shared";
30+
import { mergePrompts, readPromptsConfig, writePromptsConfig } from "perplexity-user-mcp/prompts-config";
31+
import { deletePromptHandler, resetPromptHandler, savePromptHandler } from "./prompts-handler.js";
3032
import { getAccountSnapshot, setLastRefreshTier } from "../auth/session.js";
3133
import { ensureVaultPassphrase, peekStoredVaultPassphrase } from "../auth/vault-passphrase.js";
3234
import { withScopedVaultPassphrase } from "../auth/scoped-env.js";
@@ -1386,6 +1388,26 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
13861388
}
13871389
break;
13881390
}
1391+
case "prompts:save":
1392+
case "prompts:delete":
1393+
case "prompts:reset": {
1394+
try {
1395+
const deps = { read: () => readPromptsConfig(), write: (cfg: ReturnType<typeof readPromptsConfig>) => writePromptsConfig(cfg) };
1396+
const outcome =
1397+
message.type === "prompts:save"
1398+
? savePromptHandler(message.payload.prompt, deps)
1399+
: message.type === "prompts:delete"
1400+
? deletePromptHandler(message.payload.name, deps)
1401+
: resetPromptHandler(message.payload.name, deps);
1402+
await this.postActionResult(message.id, outcome.ok, outcome.ok ? undefined : outcome.error);
1403+
if (outcome.ok) {
1404+
await this.postPromptsState();
1405+
}
1406+
} catch (err) {
1407+
await this.postActionResult(message.id, false, (err as Error).message);
1408+
}
1409+
break;
1410+
}
13891411
case "browser:select": {
13901412
try {
13911413
if (!this.authManager) {
@@ -1592,9 +1614,18 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
15921614
ideStatus,
15931615
rulesStatus: wsRoot ? getRulesStatuses(wsRoot) : [],
15941616
settings,
1617+
prompts: { prompts: mergePrompts(readPromptsConfig()) },
15951618
};
15961619
}
15971620

1621+
/** Push the merged built-in + user prompt list to the webview. */
1622+
private async postPromptsState(): Promise<void> {
1623+
await this.view?.webview.postMessage({
1624+
type: "prompts:state",
1625+
payload: { prompts: mergePrompts(readPromptsConfig()) },
1626+
} satisfies ExtensionMessage);
1627+
}
1628+
15981629
async refresh(): Promise<void> {
15991630
if (!this.view) {
16001631
return;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* Pure handlers for the Prompts tab messages (prompts:save / delete / reset),
3+
* extracted from DashboardProvider so the validation + mutation decisions stay
4+
* testable without a full vscode shim. The provider injects real
5+
* read/write deps (readPromptsConfig/writePromptsConfig) and handles the
6+
* postActionResult + postPromptsState wiring around the returned outcome.
7+
*
8+
* The merge/read/write/upsert primitives themselves live in (and are tested by)
9+
* the mcp-server `prompts-config` module — this layer is just the extension-side
10+
* validation and dispatch.
11+
*/
12+
import {
13+
BUILTIN_PROMPTS,
14+
CUSTOM_NAME_RE,
15+
deleteCustomPrompt,
16+
resetBuiltin,
17+
upsertPrompt,
18+
type PromptsConfig,
19+
type StoredPrompt,
20+
} from "perplexity-user-mcp/prompts-config";
21+
22+
export interface PromptsHandlerDeps {
23+
read: () => PromptsConfig;
24+
write: (config: PromptsConfig) => void;
25+
}
26+
27+
export type PromptsOutcome = { ok: true } | { ok: false; error: string };
28+
29+
/** Shape the webview sends (defensively typed — it crosses the message boundary). */
30+
export interface IncomingPrompt {
31+
name?: string;
32+
description?: string;
33+
arguments?: Array<{ name?: string; description?: string; required?: boolean }>;
34+
template?: string;
35+
}
36+
37+
/** True when `name` matches a shipped built-in (override target, not deletable). */
38+
export function isBuiltinName(name: string): boolean {
39+
return BUILTIN_PROMPTS.some((b) => b.name === name);
40+
}
41+
42+
/**
43+
* Validate + persist a saved prompt. A built-in name writes an override; any
44+
* other name must satisfy CUSTOM_NAME_RE. The template must be non-empty.
45+
*/
46+
export function savePromptHandler(incoming: IncomingPrompt | undefined, deps: PromptsHandlerDeps): PromptsOutcome {
47+
const name = (incoming?.name ?? "").trim();
48+
if (!name || (!isBuiltinName(name) && !CUSTOM_NAME_RE.test(name))) {
49+
return { ok: false, error: "Invalid command name — use lowercase letters, digits, and hyphens (must start with a letter)." };
50+
}
51+
if (!String(incoming?.template ?? "").trim()) {
52+
return { ok: false, error: "Template cannot be empty." };
53+
}
54+
const args = Array.isArray(incoming?.arguments) ? incoming!.arguments : [];
55+
const stored: StoredPrompt = {
56+
name,
57+
description: incoming?.description ?? "",
58+
arguments: args
59+
.filter((a) => a && typeof a.name === "string" && a.name.length > 0)
60+
.map((a) => ({ name: a.name as string, description: a.description ?? "", required: !!a.required })),
61+
template: String(incoming?.template ?? ""),
62+
};
63+
deps.write(upsertPrompt(deps.read(), stored));
64+
return { ok: true };
65+
}
66+
67+
/** Remove a custom prompt by name (built-ins are unaffected). */
68+
export function deletePromptHandler(name: string, deps: PromptsHandlerDeps): PromptsOutcome {
69+
if (!name) return { ok: false, error: "Missing prompt name." };
70+
deps.write(deleteCustomPrompt(deps.read(), name));
71+
return { ok: true };
72+
}
73+
74+
/** Clear a built-in's override, reverting it to the shipped default. */
75+
export function resetPromptHandler(name: string, deps: PromptsHandlerDeps): PromptsOutcome {
76+
if (!name) return { ok: false, error: "Missing prompt name." };
77+
deps.write(resetBuiltin(deps.read(), name));
78+
return { ok: true };
79+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, expect, it } from "vitest";
2+
import { BUILTIN_PROMPTS, type PromptsConfig } from "perplexity-user-mcp/prompts-config";
3+
import {
4+
deletePromptHandler,
5+
resetPromptHandler,
6+
savePromptHandler,
7+
} from "../src/webview/prompts-handler.js";
8+
9+
/**
10+
* Pure handlers for prompts:save / delete / reset, extracted from
11+
* DashboardProvider. The merge/upsert/read/write primitives are covered by the
12+
* mcp-server prompts-config tests; here we verify the extension-side validation
13+
* and dispatch. The postActionResult/postPromptsState wiring is exercised by the
14+
* smoke checklist in docs/smoke-tests.md.
15+
*/
16+
function makeDeps(initial: PromptsConfig = { overrides: {}, custom: [] }) {
17+
let config: PromptsConfig = initial;
18+
let writes = 0;
19+
return {
20+
deps: {
21+
read: () => config,
22+
write: (c: PromptsConfig) => {
23+
config = c;
24+
writes += 1;
25+
},
26+
},
27+
current: () => config,
28+
writeCount: () => writes,
29+
};
30+
}
31+
32+
const validCustom = {
33+
name: "my-cmd",
34+
description: "mine",
35+
arguments: [{ name: "q", description: "the query", required: true }],
36+
template: "Search: {q}",
37+
};
38+
39+
describe("savePromptHandler", () => {
40+
it("persists a valid custom prompt", () => {
41+
const h = makeDeps();
42+
const out = savePromptHandler(validCustom, h.deps);
43+
expect(out.ok).toBe(true);
44+
expect(h.current().custom.map((c) => c.name)).toEqual(["my-cmd"]);
45+
});
46+
47+
it("rejects an invalid name without writing", () => {
48+
const h = makeDeps();
49+
const out = savePromptHandler({ ...validCustom, name: "Bad Name" }, h.deps);
50+
expect(out).toEqual({ ok: false, error: expect.stringContaining("Invalid command name") });
51+
expect(h.writeCount()).toBe(0);
52+
});
53+
54+
it("rejects an empty template without writing", () => {
55+
const h = makeDeps();
56+
const out = savePromptHandler({ ...validCustom, template: " " }, h.deps);
57+
expect(out).toEqual({ ok: false, error: expect.stringContaining("Template cannot be empty") });
58+
expect(h.writeCount()).toBe(0);
59+
});
60+
61+
it("writes an override (not a custom entry) for a built-in name", () => {
62+
const builtin = BUILTIN_PROMPTS[0].name;
63+
const h = makeDeps();
64+
const out = savePromptHandler({ ...validCustom, name: builtin }, h.deps);
65+
expect(out.ok).toBe(true);
66+
expect(h.current().overrides[builtin]).toBeDefined();
67+
expect(h.current().custom).toHaveLength(0);
68+
});
69+
70+
it("drops arguments without names and coerces required", () => {
71+
const h = makeDeps();
72+
savePromptHandler(
73+
{
74+
name: "argy",
75+
description: "",
76+
arguments: [
77+
{ name: "keep", description: "", required: 1 as unknown as boolean },
78+
{ name: "", description: "nameless", required: true },
79+
],
80+
template: "{keep}",
81+
},
82+
h.deps,
83+
);
84+
const saved = h.current().custom[0];
85+
expect(saved.arguments).toEqual([{ name: "keep", description: "", required: true }]);
86+
});
87+
88+
it("rejects a missing payload", () => {
89+
const h = makeDeps();
90+
expect(savePromptHandler(undefined, h.deps).ok).toBe(false);
91+
expect(h.writeCount()).toBe(0);
92+
});
93+
});
94+
95+
describe("deletePromptHandler", () => {
96+
it("removes the named custom prompt", () => {
97+
const h = makeDeps({ overrides: {}, custom: [{ name: "a", description: "", arguments: [], template: "x" }] });
98+
const out = deletePromptHandler("a", h.deps);
99+
expect(out.ok).toBe(true);
100+
expect(h.current().custom).toHaveLength(0);
101+
});
102+
103+
it("rejects an empty name", () => {
104+
const h = makeDeps();
105+
expect(deletePromptHandler("", h.deps).ok).toBe(false);
106+
expect(h.writeCount()).toBe(0);
107+
});
108+
});
109+
110+
describe("resetPromptHandler", () => {
111+
it("clears a built-in override", () => {
112+
const builtin = BUILTIN_PROMPTS[0].name;
113+
const h = makeDeps({ overrides: { [builtin]: { name: builtin, description: "x", arguments: [], template: "y" } }, custom: [] });
114+
const out = resetPromptHandler(builtin, h.deps);
115+
expect(out.ok).toBe(true);
116+
expect(h.current().overrides[builtin]).toBeUndefined();
117+
});
118+
119+
it("is a no-op (still ok) when there is no override", () => {
120+
const h = makeDeps();
121+
expect(resetPromptHandler(BUILTIN_PROMPTS[0].name, h.deps).ok).toBe(true);
122+
});
123+
});

packages/mcp-server/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,31 @@ Path: `~/.config/opencode/opencode.json`.
273273
- `perplexity_login` — returns login instructions (interactive login runs via the CLI / extension)
274274
- `perplexity_doctor` — run diagnostic checks across browser, profile, auth, and network and return a Markdown report (pass `probe:true` for a live search probe)
275275

276+
## Prompts (custom slash-commands)
277+
278+
The server also exposes MCP **prompts** — reusable templates that show up as `/slash-commands` in clients that support them (Claude Desktop, Cursor, …). Five are built in:
279+
280+
- `perplexity.researchPlan` `{topic}` · `perplexity.reasoningPlan` `{question}`
281+
- `perplexity-fact-check` `{claim}` · `perplexity-compare` `{optionA} {optionB}` · `perplexity-latest` `{topic}`
282+
283+
You can add your own (and override/reset the built-ins) without touching code — they're read from `~/.perplexity-mcp/prompts.json`:
284+
285+
```json
286+
{
287+
"overrides": {},
288+
"custom": [
289+
{
290+
"name": "my-summary",
291+
"description": "Summarize a URL with citations",
292+
"arguments": [{ "name": "url", "description": "Page to summarize", "required": true }],
293+
"template": "Use perplexity_search to read {url} and summarize the key points with citations."
294+
}
295+
]
296+
}
297+
```
298+
299+
`{arg}` placeholders are substituted with the values the client passes. Built-in names live under `overrides`; everything else is a `custom` entry. The **VS Code extension's "Prompts" tab** is a visual editor for this file. Clients cache the prompt list at connect-time, so reconnect (or reload the window) after editing to pick up changes; the embedded daemon refreshes on its next connection automatically.
300+
276301
## Search sources and advanced queries
277302

278303
Search-style tools support Perplexity source focus through a `sources` argument:

packages/mcp-server/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@
8888
"types": "./dist/config.d.ts",
8989
"import": "./dist/config.mjs"
9090
},
91+
"./prompts-config": {
92+
"types": "./dist/prompts-config.d.ts",
93+
"import": "./dist/prompts-config.mjs"
94+
},
9195
"./refresh": {
9296
"types": "./dist/refresh.d.ts",
9397
"import": "./dist/refresh.mjs"

0 commit comments

Comments
 (0)