From f23c0d61d4d3c30859e508daae23c8d4279da480 Mon Sep 17 00:00:00 2001 From: Allen Byrd <125306425+allenfbyrd@users.noreply.github.com> Date: Sun, 24 May 2026 16:26:15 -0400 Subject: [PATCH] feat: async-job pattern tools for sonar-deep-research Adds perplexity_research_start / _poll / _cancel as additive tools that expose the existing sonar-deep-research model via a job-id + poll pattern. This works against MCP clients whose tools/call timeout is shorter than typical deep-research wall-clock AND which don't include _meta.progressToken on requests (e.g. Claude Desktop v1.8555). The existing perplexity_research tool is unchanged; this is purely additive. Complementary to the notifications/progress fix proposed in #110 (which works for clients that DO request progress). Refs: #110 --- README.md | 17 ++ src/server.test.ts | 125 +++++++++++- src/server.ts | 464 +++++++++++++++++++++++++++++++++++++++++- src/transport.test.ts | 7 +- 4 files changed, 609 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 49db451..eafcd29 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/src/server.test.ts b/src/server.test.ts index d1efb3f..90c0941 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -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", () => { @@ -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); + }); + }); +}); + diff --git a/src/server.ts b/src/server.ts index d649ccb..8fecc80 100644 --- a/src/server.ts +++ b/src/server.ts @@ -189,6 +189,352 @@ export async function consumeSSEStream(response: Response): Promise POSTs to /v1/async/sonar; stores an +// in-flight Promise in a local in-memory +// JobStore; returns IMMEDIATELY (<1 sec) +// with {jobId, status: CREATED|IN_PROGRESS}. +// perplexity_research_poll -> Looks up jobId in the store; races the +// in-flight Promise against a configurable +// poll budget timer (default 45 sec, well +// under typical 60-sec client caps); returns +// current status (and the full response +// payload when COMPLETED). +// perplexity_research_cancel -> Marks the local job as cancelled and frees +// its slot. The Perplexity async API does +// not currently expose a cancel endpoint; +// the upstream job may still complete and +// consume API quota. +// +// Endpoints used: POST/GET https://api.perplexity.ai/v1/async/sonar[/{id}] +// +// Env overrides: +// PERPLEXITY_ASYNC_MAX_WAIT_MS (default 900000 / 15 min) +// PERPLEXITY_TIMEOUT_MS (default 300000 / 5 min) +// PERPLEXITY_RESEARCH_JOB_TTL_MS (default 1800000 / 30 min) +// PERPLEXITY_RESEARCH_POLL_BUDGET_MS (default 45000 / 45 sec) +// PERPLEXITY_RESEARCH_SWEEP_INTERVAL_MS (default 300000 / 5 min) +// --------------------------------------------------------------------------- + +const ASYNC_POLL_INTERVALS_SEC = [8, 8, 12, 18, 27, 40]; + +function envInt(name: string, defaultMs: number): number { + const raw = process.env[name]; + if (!raw) return defaultMs; + const parsed = parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultMs; +} + +type ResearchJobStatus = + | "CREATED" + | "IN_PROGRESS" + | "COMPLETED" + | "FAILED" + | "CANCELLED"; + +interface ResearchJobHandle { + jobId: string; + startedAt: number; + lastObservedAt: number; + status: ResearchJobStatus; + resultPromise: Promise; + response?: string; + error?: string; + cancelled: boolean; +} + +export interface ResearchJobPayload { + jobId: string; + status: ResearchJobStatus | "NOT_FOUND"; + response?: string; + error?: string; + elapsedSec: number; + message: string; + // Index signature so this type satisfies the SDK's structuredContent + // constraint (Record). Named field types remain enforced. + [k: string]: unknown; +} + +async function makeGetRequest( + endpoint: string, + serviceOrigin: string | undefined, +): Promise { + if (!PERPLEXITY_API_KEY) { + throw new Error("PERPLEXITY_API_KEY environment variable is required"); + } + + const TIMEOUT_MS = envInt("PERPLEXITY_TIMEOUT_MS", 300000); + const url = new URL(`${PERPLEXITY_BASE_URL}/${endpoint}`); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); + + let response: Response; + try { + const headers: Record = { + "Authorization": `Bearer ${PERPLEXITY_API_KEY}`, + "User-Agent": `perplexity-mcp/${VERSION}`, + "X-Source": "pplx-mcp-server", + }; + if (serviceOrigin) { + headers["X-Service"] = serviceOrigin; + } + response = await proxyAwareFetch(url.toString(), { + method: "GET", + headers, + signal: controller.signal, + }); + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`Request timeout: Perplexity API did not respond within ${TIMEOUT_MS}ms.`); + } + throw new Error(`Network error while calling Perplexity API: ${error}`); + } + clearTimeout(timeoutId); + + if (!response.ok) { + let errorText: string; + try { + errorText = await response.text(); + } catch { + errorText = "Unable to parse error response"; + } + throw new Error( + `Perplexity API error: ${response.status} ${response.statusText}\n${errorText}` + ); + } + + return response; +} + +export class ResearchJobStore { + private jobs = new Map(); + private readonly jobTtlMs: number; + private readonly pollBudgetMs: number; + + constructor() { + this.jobTtlMs = envInt("PERPLEXITY_RESEARCH_JOB_TTL_MS", 30 * 60 * 1000); + this.pollBudgetMs = envInt("PERPLEXITY_RESEARCH_POLL_BUDGET_MS", 45 * 1000); + const sweepIntervalMs = envInt("PERPLEXITY_RESEARCH_SWEEP_INTERVAL_MS", 5 * 60 * 1000); + const sweeper = setInterval(() => this.sweep(), sweepIntervalMs); + // Don't keep the event loop alive purely for the sweeper. + if (typeof (sweeper as unknown as { unref?: () => void }).unref === "function") { + (sweeper as unknown as { unref: () => void }).unref(); + } + } + + async start( + messages: Message[], + model: string, + stripThinking: boolean, + serviceOrigin: string | undefined, + options: ChatCompletionOptions | undefined, + ): Promise<{ jobId: string; status: ResearchJobStatus }> { + const submitBody: Record = { + request: { + model, + messages, + ...(options?.search_recency_filter && { search_recency_filter: options.search_recency_filter }), + ...(options?.search_domain_filter && { search_domain_filter: options.search_domain_filter }), + ...(options?.search_context_size && { web_search_options: { search_context_size: options.search_context_size } }), + ...(options?.reasoning_effort && { reasoning_effort: options.reasoning_effort }), + }, + }; + + const submitResp = await makeApiRequest("v1/async/sonar", submitBody, serviceOrigin); + const submitJson = (await submitResp.json()) as { id?: string; status?: string }; + if (!submitJson.id) { + throw new Error(`Perplexity async submit returned no job id: ${JSON.stringify(submitJson)}`); + } + const jobId = submitJson.id; + const status: ResearchJobStatus = (submitJson.status as ResearchJobStatus) || "CREATED"; + + const handle: ResearchJobHandle = { + jobId, + startedAt: Date.now(), + lastObservedAt: Date.now(), + status, + resultPromise: Promise.resolve(""), + cancelled: false, + }; + handle.resultPromise = this.runPollLoop(handle, stripThinking, serviceOrigin); + // Suppress unhandledRejection — the resultPromise rejection is consumed + // lazily by poll(jobId) callers and may go unread for the job's TTL. + handle.resultPromise.catch(() => { /* status/error already recorded on handle */ }); + this.jobs.set(jobId, handle); + return { jobId, status }; + } + + async poll(jobId: string): Promise { + const handle = this.jobs.get(jobId); + if (!handle) { + return { + jobId, + status: "NOT_FOUND", + elapsedSec: 0, + message: `No job with id ${jobId}. Either it expired (TTL=${Math.round(this.jobTtlMs/60000)} min) or never existed.`, + }; + } + // Race the in-flight resultPromise against the configurable poll budget, + // leaving headroom under typical client tools/call caps. + await Promise.race([ + handle.resultPromise.then(() => undefined).catch(() => undefined), + new Promise((r) => setTimeout(r, this.pollBudgetMs)), + ]); + const elapsedSec = Math.round((Date.now() - handle.startedAt) / 1000); + const payload: ResearchJobPayload = { + jobId, + status: handle.status, + elapsedSec, + message: "", + }; + if (handle.status === "COMPLETED" && handle.response !== undefined) { + payload.response = handle.response; + payload.message = `Job completed in ${elapsedSec}s. The 'response' field contains the full research with citations.`; + return payload; + } + if (handle.status === "FAILED") { + payload.error = handle.error || "Unknown error"; + payload.message = `Job failed after ${elapsedSec}s. See 'error' field.`; + return payload; + } + if (handle.status === "CANCELLED") { + payload.message = `Job cancelled at ${elapsedSec}s. No result available.`; + return payload; + } + payload.message = `Job still running (status=${handle.status}, elapsed=${elapsedSec}s). Call perplexity_research_poll again with the same jobId.`; + return payload; + } + + cancel(jobId: string): ResearchJobPayload { + const handle = this.jobs.get(jobId); + if (!handle) { + return { + jobId, + status: "NOT_FOUND", + elapsedSec: 0, + message: `No job with id ${jobId}.`, + }; + } + handle.cancelled = true; + if (handle.status !== "COMPLETED" && handle.status !== "FAILED") { + handle.status = "CANCELLED"; + } + return { + jobId, + status: handle.status, + elapsedSec: Math.round((Date.now() - handle.startedAt) / 1000), + message: "Job marked cancelled locally. The underlying Perplexity API job may still complete server-side (no cancel endpoint upstream).", + }; + } + + /** Test helper — visible for vitest. Not part of stable API. */ + _hasJob(jobId: string): boolean { + return this.jobs.has(jobId); + } + + /** Test helper — visible for vitest. Not part of stable API. */ + _jobCount(): number { + return this.jobs.size; + } + + private async runPollLoop( + handle: ResearchJobHandle, + stripThinking: boolean, + serviceOrigin: string | undefined, + ): Promise { + const MAX_WAIT_MS = envInt("PERPLEXITY_ASYNC_MAX_WAIT_MS", 15 * 60 * 1000); + const deadline = Date.now() + MAX_WAIT_MS; + let iter = 0; + let lastJson: { status?: string; response?: unknown; error_message?: string } = { status: handle.status }; + + while (Date.now() < deadline && !handle.cancelled) { + const idx = Math.min(iter, ASYNC_POLL_INTERVALS_SEC.length - 1); + const waitSec = ASYNC_POLL_INTERVALS_SEC[idx]; + await new Promise((r) => setTimeout(r, waitSec * 1000)); + iter++; + if (handle.cancelled) break; + + try { + const pollResp = await makeGetRequest(`v1/async/sonar/${handle.jobId}`, serviceOrigin); + lastJson = (await pollResp.json()) as { status?: string; response?: unknown; error_message?: string }; + const reported = lastJson.status as ResearchJobStatus | undefined; + if (reported) { + handle.status = reported; + } + handle.lastObservedAt = Date.now(); + } catch { + continue; + } + + if (handle.status === "COMPLETED") break; + if (handle.status === "FAILED") { + handle.error = lastJson.error_message || "Perplexity reported FAILED with no error_message"; + throw new Error(handle.error); + } + } + + if (handle.cancelled) { + handle.status = "CANCELLED"; + throw new Error("Job cancelled locally"); + } + if (handle.status !== "COMPLETED") { + handle.status = "FAILED"; + handle.error = `Job did not complete within ${MAX_WAIT_MS}ms (last status: ${handle.status})`; + throw new Error(handle.error); + } + + let data: ChatCompletionResponse; + try { + data = ChatCompletionResponseSchema.parse(lastJson.response); + } catch (error) { + handle.status = "FAILED"; + handle.error = `Failed to parse async response from Perplexity API: ${error}`; + throw new Error(handle.error); + } + + const firstChoice = data.choices[0]; + let messageContent = firstChoice.message.content; + if (stripThinking) { + messageContent = stripThinkingTokens(messageContent); + } + if (data.citations && Array.isArray(data.citations) && data.citations.length > 0) { + messageContent += "\n\nCitations:\n"; + data.citations.forEach((citation, index) => { + messageContent += `[${index + 1}] ${citation}\n`; + }); + } + handle.response = messageContent; + return messageContent; + } + + private sweep(): void { + const now = Date.now(); + for (const [jobId, handle] of this.jobs) { + if (now - handle.startedAt > this.jobTtlMs) { + this.jobs.delete(jobId); + } + } + } +} + +// Singleton — one job store per server process. +const RESEARCH_JOBS = new ResearchJobStore(); export async function performChatCompletion( messages: Message[], model: string = "sonar-pro", @@ -309,7 +655,7 @@ export function createPerplexityServer(serviceOrigin?: string) { "Perplexity AI server for web-grounded search, research, and reasoning. " + "Use perplexity_search for finding URLs, facts, and recent news. " + "Use perplexity_ask for quick AI-answered questions with citations. Supports recency filters, domain restrictions, and search context size control. " + - "Use perplexity_research for in-depth multi-source investigation (slow, 30s+). Supports reasoning_effort parameter to control depth. " + + "For in-depth multi-source investigation: use perplexity_research for a single tool call (best for MCP clients with progress-notification support / long tools/call timeouts), OR use perplexity_research_start + perplexity_research_poll for clients with short hardcoded timeouts. Both back the Sonar Deep Research model and support reasoning_effort. " + "Use perplexity_reason for complex analysis requiring step-by-step logic. Supports recency filters, domain restrictions, and search context size control. " + "All tools are read-only and access live web data.", } @@ -439,6 +785,122 @@ export function createPerplexityServer(serviceOrigin?: string) { } ); + const researchJobOutputSchema = { + jobId: z.string().describe("Opaque job identifier. Pass this to perplexity_research_poll / perplexity_research_cancel."), + status: z.string().describe("Job status: CREATED | IN_PROGRESS | COMPLETED | FAILED | CANCELLED | NOT_FOUND."), + response: z.string().optional().describe("Full research text with citations. Populated only when status == COMPLETED."), + error: z.string().optional().describe("Error description. Populated only when status == FAILED."), + elapsedSec: z.number().describe("Wall-clock seconds since the job was started."), + message: z.string().describe("Human-readable next-step guidance for the model."), + }; + + server.registerTool( + "perplexity_research_start", + { + title: "Deep Research (start)", + description: "Launch a deep, multi-source research job (Sonar Deep Research model) under the async-job pattern. " + + "Returns IMMEDIATELY (<1 sec) with a jobId, then YOU MUST call perplexity_research_poll(jobId) every 30-60 sec until status == COMPLETED or FAILED. " + + "Use this tool (rather than perplexity_research) if your MCP client has a hardcoded tools/call timeout shorter than typical deep-research wall-clock (60-300 sec). " + + "Jobs typically take 60-300 sec; outliers 5-15 min. " + + "For quick factual questions, use perplexity_ask instead. " + + "For logical analysis and reasoning, use perplexity_reason instead.", + inputSchema: researchInputSchema as any, + outputSchema: researchJobOutputSchema as any, + annotations: { + readOnlyHint: true, + openWorldHint: true, + idempotentHint: false, + destructiveHint: false, + }, + }, + async (args: any) => { + const { messages, strip_thinking, reasoning_effort } = args as { + messages: Message[]; + strip_thinking?: boolean; + reasoning_effort?: "minimal" | "low" | "medium" | "high"; + }; + validateMessages(messages, "perplexity_research_start"); + const stripThinking = typeof strip_thinking === "boolean" ? strip_thinking : false; + const options = { + ...(reasoning_effort && { reasoning_effort }), + }; + const { jobId, status } = await RESEARCH_JOBS.start( + messages, + "sonar-deep-research", + stripThinking, + serviceOrigin, + Object.keys(options).length > 0 ? options : undefined, + ); + const payload = { + jobId, + status, + elapsedSec: 0, + message: `Deep-research job ${jobId} started (status=${status}). Call perplexity_research_poll with this jobId every 30-60 sec until status == COMPLETED.`, + }; + return { + content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, + }; + } + ); + + server.registerTool( + "perplexity_research_poll", + { + title: "Deep Research (poll)", + description: "Poll a previously-started deep research job. Returns within the configured poll budget (default 45 sec) with the current status. " + + "If status == COMPLETED, the 'response' field contains the full research with citations — USE THAT as the answer. " + + "If status == IN_PROGRESS or CREATED, call this tool again immediately (the call itself blocks up to the poll budget). " + + "If status == FAILED, the 'error' field explains why. " + + "If status == NOT_FOUND, the job expired (retention is 30 min by default; configurable via PERPLEXITY_RESEARCH_JOB_TTL_MS).", + inputSchema: { + jobId: z.string().describe("The jobId returned by perplexity_research_start."), + } as any, + outputSchema: researchJobOutputSchema as any, + annotations: { + readOnlyHint: true, + openWorldHint: false, + idempotentHint: true, + destructiveHint: false, + }, + }, + async (args: any) => { + const { jobId } = args as { jobId: string }; + const payload = await RESEARCH_JOBS.poll(jobId); + return { + content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, + }; + } + ); + + server.registerTool( + "perplexity_research_cancel", + { + title: "Deep Research (cancel)", + description: "Mark a deep research job as cancelled and free its slot in the local job store. " + + "Note: 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 and frees memory.", + inputSchema: { + jobId: z.string().describe("The jobId to cancel."), + } as any, + outputSchema: researchJobOutputSchema as any, + annotations: { + readOnlyHint: false, + openWorldHint: false, + idempotentHint: true, + destructiveHint: true, + }, + }, + async (args: any) => { + const { jobId } = args as { jobId: string }; + const payload = RESEARCH_JOBS.cancel(jobId); + return { + content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, + }; + } + ); + server.registerTool( "perplexity_reason", { diff --git a/src/transport.test.ts b/src/transport.test.ts index 6dfcf7e..dd130ca 100644 --- a/src/transport.test.ts +++ b/src/transport.test.ts @@ -149,12 +149,15 @@ describe("Transport Integration Tests", () => { expect(data.id).toBe(1); expect(data.result).toBeDefined(); expect(data.result.tools).toBeDefined(); - expect(data.result.tools).toHaveLength(4); + expect(data.result.tools).toHaveLength(7); - // Verify all four tools are present + // Verify all seven tools are present (4 base + 3 async-job tools for sonar-deep-research) const toolNames = data.result.tools.map((t: { name: string }) => t.name); expect(toolNames).toContain("perplexity_ask"); expect(toolNames).toContain("perplexity_research"); + expect(toolNames).toContain("perplexity_research_start"); + expect(toolNames).toContain("perplexity_research_poll"); + expect(toolNames).toContain("perplexity_research_cancel"); expect(toolNames).toContain("perplexity_reason"); expect(toolNames).toContain("perplexity_search");