Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ General-purpose conversational AI with real-time web search using the `sonar-pro
### **perplexity_research**
Deep, comprehensive research using the `sonar-deep-research` model. Ideal for thorough analysis and detailed reports.

### **perplexity_research_start** / **perplexity_research_poll** / **perplexity_research_cancel**

Same `sonar-deep-research` model as `perplexity_research`, exposed via a **job-id + poll** pattern. Use these tools (instead of `perplexity_research`) if your MCP client has a hardcoded `tools/call` timeout shorter than typical deep-research wall-clock (60–300+ seconds) AND does not include `_meta.progressToken` on requests — for example, Claude Desktop as of v1.8555. See [issue #110](https://github.com/perplexityai/modelcontextprotocol/issues/110) for the related notifications/progress fix which helps clients that DO request progress.

Flow:

1. `perplexity_research_start({ messages, ... })` → returns `{ jobId, status: "CREATED" }` in **< 1 second**.
2. `perplexity_research_poll({ jobId })` → blocks for up to the configured poll budget (default 45 seconds, well under typical 60-second client caps) and returns the current status. Call repeatedly until `status == "COMPLETED"` (response delivered) or `"FAILED"`.
3. `perplexity_research_cancel({ jobId })` → marks the local job as cancelled. The Perplexity async API does not currently expose a cancel endpoint, so the upstream job may still complete and consume API quota; this call only stops local polling.

The two tools accept the same inputs as `perplexity_research` and back the same model — they differ only in delivery mechanism.

### **perplexity_reason**
Advanced reasoning and problem-solving using the `sonar-reasoning-pro` model. Perfect for complex analytical tasks.

Expand All @@ -38,6 +50,11 @@ Advanced reasoning and problem-solving using the `sonar-reasoning-pro` model. Pe
3. (Optional) Set timeout: `PERPLEXITY_TIMEOUT_MS=600000` (default: 5 minutes)
4. (Optional) Set custom base URL: `PERPLEXITY_BASE_URL=https://your-custom-url.com` (default: https://api.perplexity.ai)
5. (Optional) Set log level: `PERPLEXITY_LOG_LEVEL=DEBUG|INFO|WARN|ERROR` (default: ERROR)
6. (Optional) Async-job pattern tuning (only affects `perplexity_research_start` / `_poll` / `_cancel`):
- `PERPLEXITY_ASYNC_MAX_WAIT_MS=900000` (default 15 min) — hard ceiling on a single async job's lifetime before it is force-failed
- `PERPLEXITY_RESEARCH_JOB_TTL_MS=1800000` (default 30 min) — how long a completed/failed job's result is retained in memory for `_poll` retrieval
- `PERPLEXITY_RESEARCH_POLL_BUDGET_MS=45000` (default 45 sec) — how long each `_poll` call blocks waiting for status to change. Set lower than your MCP client's `tools/call` timeout
- `PERPLEXITY_RESEARCH_SWEEP_INTERVAL_MS=300000` (default 5 min) — how often the in-memory job store sweeps expired entries

### Claude Code

Expand Down
125 changes: 124 additions & 1 deletion src/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { stripThinkingTokens, getProxyUrl, proxyAwareFetch, validateMessages } from "./server.js";
import { stripThinkingTokens, getProxyUrl, proxyAwareFetch, validateMessages, ResearchJobStore } from "./server.js";

describe("Server Utility Functions", () => {
describe("stripThinkingTokens", () => {
Expand Down Expand Up @@ -254,3 +254,126 @@ describe("Server Utility Functions", () => {
});
});
});

describe("ResearchJobStore", () => {
let originalFetch: typeof globalThis.fetch;
let originalEnv: NodeJS.ProcessEnv;

beforeEach(() => {
originalFetch = globalThis.fetch;
originalEnv = { ...process.env };
// Speed up the tests — keep poll budget short so they don't drag.
process.env.PERPLEXITY_RESEARCH_POLL_BUDGET_MS = "20";
process.env.PERPLEXITY_RESEARCH_SWEEP_INTERVAL_MS = "60000"; // 1 min, irrelevant
process.env.PERPLEXITY_ASYNC_MAX_WAIT_MS = "5000"; // small ceiling for failure path
});

afterEach(() => {
globalThis.fetch = originalFetch;
process.env = originalEnv;
vi.restoreAllMocks();
});

function mockFetchOnce(responseBody: unknown, opts: { ok?: boolean; status?: number; statusText?: string } = {}) {
const ok = opts.ok ?? true;
const status = opts.status ?? 200;
const statusText = opts.statusText ?? "OK";
const response = {
ok,
status,
statusText,
json: async () => responseBody,
text: async () => JSON.stringify(responseBody),
} as unknown as Response;
return vi.fn().mockResolvedValueOnce(response);
}

describe("start", () => {
it("returns jobId + status when the submit POST succeeds", async () => {
globalThis.fetch = mockFetchOnce({ id: "job-abc-123", status: "CREATED" });
process.env.PERPLEXITY_API_KEY = "test-api-key";
const store = new ResearchJobStore();
const result = await store.start(
[{ role: "user", content: "hello" }],
"sonar-deep-research",
false,
undefined,
undefined,
);
expect(result.jobId).toBe("job-abc-123");
expect(result.status).toBe("CREATED");
expect(store._hasJob("job-abc-123")).toBe(true);
expect(store._jobCount()).toBe(1);
});

it("defaults to CREATED status when submit response omits status", async () => {
globalThis.fetch = mockFetchOnce({ id: "job-no-status" });
process.env.PERPLEXITY_API_KEY = "test-api-key";
const store = new ResearchJobStore();
const result = await store.start([{ role: "user", content: "hi" }], "sonar-deep-research", false, undefined, undefined);
expect(result.status).toBe("CREATED");
});

it("throws when submit response has no job id", async () => {
globalThis.fetch = mockFetchOnce({ status: "CREATED" /* note: no id */ });
process.env.PERPLEXITY_API_KEY = "test-api-key";
const store = new ResearchJobStore();
await expect(
store.start([{ role: "user", content: "hi" }], "sonar-deep-research", false, undefined, undefined),
).rejects.toThrow(/no job id/);
expect(store._jobCount()).toBe(0);
});
});

describe("poll", () => {
it("returns NOT_FOUND for an unknown jobId", async () => {
const store = new ResearchJobStore();
const payload = await store.poll("does-not-exist");
expect(payload.status).toBe("NOT_FOUND");
expect(payload.elapsedSec).toBe(0);
expect(payload.message).toMatch(/No job/);
});

it("returns IN_PROGRESS state when the background poll hasn't completed within the budget", async () => {
// Submit returns CREATED. First GET (the background poll loop's poll #1) returns IN_PROGRESS.
// The poll budget is short (20 ms) so we'll race-timeout before the background loop completes.
globalThis.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true, status: 200, statusText: "OK",
json: async () => ({ id: "job-inprogress", status: "CREATED" }),
text: async () => "{}",
} as unknown as Response)
.mockResolvedValue({
ok: true, status: 200, statusText: "OK",
json: async () => ({ status: "IN_PROGRESS" }),
text: async () => "{}",
} as unknown as Response);
process.env.PERPLEXITY_API_KEY = "test-api-key";
const store = new ResearchJobStore();
const { jobId } = await store.start([{ role: "user", content: "x" }], "sonar-deep-research", false, undefined, undefined);
const payload = await store.poll(jobId);
expect(["CREATED", "IN_PROGRESS"]).toContain(payload.status);
expect(payload.response).toBeUndefined();
});
});

describe("cancel", () => {
it("returns NOT_FOUND for an unknown jobId", () => {
const store = new ResearchJobStore();
const payload = store.cancel("does-not-exist");
expect(payload.status).toBe("NOT_FOUND");
});

it("marks an active job as CANCELLED", async () => {
globalThis.fetch = mockFetchOnce({ id: "job-to-cancel", status: "CREATED" });
process.env.PERPLEXITY_API_KEY = "test-api-key";
const store = new ResearchJobStore();
const { jobId } = await store.start([{ role: "user", content: "x" }], "sonar-deep-research", false, undefined, undefined);
const payload = store.cancel(jobId);
expect(payload.status).toBe("CANCELLED");
// Job remains in store so the next poll can observe CANCELLED.
expect(store._hasJob(jobId)).toBe(true);
});
});
});

Loading